commit a2f7d3d763be000f46b1467ade8c6151af906982
parent 44ae6e14e1decbdaf5b3ff2333809cf506f938b2
Author: Volodymyr Potuzhnyi <potuzhnyi.volodymyr@gmail.com>
Date: Thu, 25 Jun 2026 11:52:49 +0200
[POS] small update(now mirrors android 1.6.0)
Diffstat:
67 files changed, 19997 insertions(+), 10 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -82,3 +82,6 @@ fastlane/test_output
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
+
+# TalerPOS build artifacts
+TalerPOS/build/
diff --git a/TalerPOS.xcodeproj/project.pbxproj b/TalerPOS.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 56;
+ objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,16 +11,93 @@
A1000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000002 /* ContentView.swift */; };
A1000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A2000003 /* Assets.xcassets */; };
A1000004 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A2000004 /* Preview Assets.xcassets */; };
+ B1000001 /* Amount.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000001 /* Amount.swift */; };
+ B1000002 /* PosConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000002 /* PosConfig.swift */; };
+ B1000003 /* Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000003 /* Order.swift */; };
+ B1000004 /* Payment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000004 /* Payment.swift */; };
+ B1000005 /* OrderHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000005 /* OrderHistoryEntry.swift */; };
+ B1000006 /* MerchantApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000006 /* MerchantApi.swift */; };
+ B1000007 /* ConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000007 /* ConfigManager.swift */; };
+ B1000008 /* OrderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000008 /* OrderManager.swift */; };
+ B1000009 /* PaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000009 /* PaymentManager.swift */; };
+ B100000A /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000A /* HistoryManager.swift */; };
+ B100000B /* RefundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000B /* RefundManager.swift */; };
+ B100000C /* ConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000C /* ConfigView.swift */; };
+ B100000D /* OrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000D /* OrderView.swift */; };
+ B100000E /* AmountEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000E /* AmountEntryView.swift */; };
+ B100000F /* PaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B200000F /* PaymentView.swift */; };
+ B1000010 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000010 /* HistoryView.swift */; };
+ B1000011 /* RefundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000011 /* RefundView.swift */; };
+ B1000012 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000012 /* SettingsView.swift */; };
+ B1000013 /* ScreenshotController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000013 /* ScreenshotController.swift */; };
+ B1000014 /* LanguageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000014 /* LanguageManager.swift */; };
+ B1000015 /* PosQRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000015 /* PosQRView.swift */; };
+ B1000016 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000016 /* QRScannerView.swift */; };
+ C1000001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C2000001 /* Localizable.xcstrings */; };
+ C1000003 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000003 /* Strings.swift */; };
+ D1000001 /* ConfigProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000001 /* ConfigProductTests.swift */; };
+ D1000002 /* OrderManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000002 /* OrderManagerTests.swift */; };
+ E1000001 /* ScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2000001 /* ScreenshotTests.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ D3000001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = A8000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A5000001;
+ remoteInfo = TalerPOS;
+ };
+ E3000001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = A8000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A5000001;
+ remoteInfo = TalerPOS;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFileReference section */
A2000001 /* TalerPOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalerPOSApp.swift; sourceTree = "<group>"; };
A2000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A2000003 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A2000004 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
A2000010 /* TalerPOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TalerPOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ B2000001 /* Amount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Amount.swift; sourceTree = "<group>"; };
+ B2000002 /* PosConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosConfig.swift; sourceTree = "<group>"; };
+ B2000003 /* Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Order.swift; sourceTree = "<group>"; };
+ B2000004 /* Payment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payment.swift; sourceTree = "<group>"; };
+ B2000005 /* OrderHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderHistoryEntry.swift; sourceTree = "<group>"; };
+ B2000006 /* MerchantApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantApi.swift; sourceTree = "<group>"; };
+ B2000007 /* ConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManager.swift; sourceTree = "<group>"; };
+ B2000008 /* OrderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderManager.swift; sourceTree = "<group>"; };
+ B2000009 /* PaymentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentManager.swift; sourceTree = "<group>"; };
+ B200000A /* HistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = "<group>"; };
+ B200000B /* RefundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundManager.swift; sourceTree = "<group>"; };
+ B200000C /* ConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigView.swift; sourceTree = "<group>"; };
+ B200000D /* OrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderView.swift; sourceTree = "<group>"; };
+ B200000E /* AmountEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountEntryView.swift; sourceTree = "<group>"; };
+ B200000F /* PaymentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentView.swift; sourceTree = "<group>"; };
+ B2000010 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
+ B2000011 /* RefundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundView.swift; sourceTree = "<group>"; };
+ B2000012 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
+ B2000013 /* ScreenshotController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotController.swift; sourceTree = "<group>"; };
+ B2000014 /* LanguageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageManager.swift; sourceTree = "<group>"; };
+ B2000015 /* PosQRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosQRView.swift; sourceTree = "<group>"; };
+ B2000016 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
+ C2000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
+ C2000003 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
+ D2000001 /* ConfigProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigProductTests.swift; sourceTree = "<group>"; };
+ D2000002 /* OrderManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderManagerTests.swift; sourceTree = "<group>"; };
+ D2000010 /* TalerPOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TalerPOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ E2000001 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = "<group>"; };
+ E2000010 /* TalerPOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TalerPOSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 6027F5812FE45E4F0010C598 /* screenshot-products */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "screenshot-products"; sourceTree = "<group>"; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
/* Begin PBXFrameworksBuildPhase section */
A3000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -29,6 +106,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D3000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E3000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -36,6 +127,8 @@
isa = PBXGroup;
children = (
A4000002 /* TalerPOS */,
+ D4000001 /* TalerPOSTests */,
+ E4000001 /* TalerPOSUITests */,
A4000010 /* Products */,
);
sourceTree = "<group>";
@@ -53,6 +146,7 @@
A400000A /* Model */,
A400000B /* Views */,
A400000C /* Resources */,
+ A400000E /* Debug */,
);
path = TalerPOS;
sourceTree = "<group>";
@@ -69,6 +163,8 @@
A4000004 /* Config */ = {
isa = PBXGroup;
children = (
+ B2000007 /* ConfigManager.swift */,
+ B2000014 /* LanguageManager.swift */,
);
path = Config;
sourceTree = "<group>";
@@ -76,6 +172,7 @@
A4000005 /* Order */ = {
isa = PBXGroup;
children = (
+ B2000008 /* OrderManager.swift */,
);
path = Order;
sourceTree = "<group>";
@@ -83,6 +180,7 @@
A4000006 /* Payment */ = {
isa = PBXGroup;
children = (
+ B2000009 /* PaymentManager.swift */,
);
path = Payment;
sourceTree = "<group>";
@@ -90,6 +188,7 @@
A4000007 /* History */ = {
isa = PBXGroup;
children = (
+ B200000A /* HistoryManager.swift */,
);
path = History;
sourceTree = "<group>";
@@ -97,6 +196,7 @@
A4000008 /* Refund */ = {
isa = PBXGroup;
children = (
+ B200000B /* RefundManager.swift */,
);
path = Refund;
sourceTree = "<group>";
@@ -104,6 +204,7 @@
A4000009 /* Networking */ = {
isa = PBXGroup;
children = (
+ B2000006 /* MerchantApi.swift */,
);
path = Networking;
sourceTree = "<group>";
@@ -111,6 +212,11 @@
A400000A /* Model */ = {
isa = PBXGroup;
children = (
+ B2000001 /* Amount.swift */,
+ B2000002 /* PosConfig.swift */,
+ B2000003 /* Order.swift */,
+ B2000004 /* Payment.swift */,
+ B2000005 /* OrderHistoryEntry.swift */,
);
path = Model;
sourceTree = "<group>";
@@ -118,6 +224,15 @@
A400000B /* Views */ = {
isa = PBXGroup;
children = (
+ B200000C /* ConfigView.swift */,
+ B200000D /* OrderView.swift */,
+ B200000E /* AmountEntryView.swift */,
+ B200000F /* PaymentView.swift */,
+ B2000010 /* HistoryView.swift */,
+ B2000011 /* RefundView.swift */,
+ B2000012 /* SettingsView.swift */,
+ B2000015 /* PosQRView.swift */,
+ B2000016 /* QRScannerView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -127,6 +242,8 @@
children = (
A2000003 /* Assets.xcassets */,
A400000D /* Preview Content */,
+ C2000001 /* Localizable.xcstrings */,
+ C2000003 /* Strings.swift */,
);
path = Resources;
sourceTree = "<group>";
@@ -139,14 +256,42 @@
path = "Preview Content";
sourceTree = "<group>";
};
+ A400000E /* Debug */ = {
+ isa = PBXGroup;
+ children = (
+ 6027F5812FE45E4F0010C598 /* screenshot-products */,
+ B2000013 /* ScreenshotController.swift */,
+ );
+ path = Debug;
+ sourceTree = "<group>";
+ };
A4000010 /* Products */ = {
isa = PBXGroup;
children = (
A2000010 /* TalerPOS.app */,
+ D2000010 /* TalerPOSTests.xctest */,
+ E2000010 /* TalerPOSUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
+ D4000001 /* TalerPOSTests */ = {
+ isa = PBXGroup;
+ children = (
+ D2000001 /* ConfigProductTests.swift */,
+ D2000002 /* OrderManagerTests.swift */,
+ );
+ path = TalerPOSTests;
+ sourceTree = "<group>";
+ };
+ E4000001 /* TalerPOSUITests */ = {
+ isa = PBXGroup;
+ children = (
+ E2000001 /* ScreenshotTests.swift */,
+ );
+ path = TalerPOSUITests;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -162,11 +307,50 @@
);
dependencies = (
);
+ fileSystemSynchronizedGroups = (
+ 6027F5812FE45E4F0010C598 /* screenshot-products */,
+ );
name = TalerPOS;
productName = TalerPOS;
productReference = A2000010 /* TalerPOS.app */;
productType = "com.apple.product-type.application";
};
+ D5000001 /* TalerPOSTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D7000001 /* Build configuration list for PBXNativeTarget "TalerPOSTests" */;
+ buildPhases = (
+ D6000001 /* Sources */,
+ D3000002 /* Frameworks */,
+ D6000002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ D3000003 /* PBXTargetDependency */,
+ );
+ name = TalerPOSTests;
+ productName = TalerPOSTests;
+ productReference = D2000010 /* TalerPOSTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ E5000001 /* TalerPOSUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E7000001 /* Build configuration list for PBXNativeTarget "TalerPOSUITests" */;
+ buildPhases = (
+ E6000001 /* Sources */,
+ E3000002 /* Frameworks */,
+ E6000002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ E3000003 /* PBXTargetDependency */,
+ );
+ name = TalerPOSUITests;
+ productName = TalerPOSUITests;
+ productReference = E2000010 /* TalerPOSUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -180,6 +364,14 @@
A5000001 = {
CreatedOnToolsVersion = 15.4;
};
+ D5000001 = {
+ CreatedOnToolsVersion = 15.4;
+ TestTargetID = A5000001;
+ };
+ E5000001 = {
+ CreatedOnToolsVersion = 15.4;
+ TestTargetID = A5000001;
+ };
};
};
buildConfigurationList = A7000001 /* Build configuration list for PBXProject "TalerPOS" */;
@@ -196,6 +388,8 @@
projectRoot = "";
targets = (
A5000001 /* TalerPOS */,
+ D5000001 /* TalerPOSTests */,
+ E5000001 /* TalerPOSUITests */,
);
};
/* End PBXProject section */
@@ -207,6 +401,21 @@
files = (
A1000004 /* Preview Assets.xcassets in Resources */,
A1000003 /* Assets.xcassets in Resources */,
+ C1000001 /* Localizable.xcstrings in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D6000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E6000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -219,11 +428,64 @@
files = (
A1000001 /* TalerPOSApp.swift in Sources */,
A1000002 /* ContentView.swift in Sources */,
+ B1000001 /* Amount.swift in Sources */,
+ B1000002 /* PosConfig.swift in Sources */,
+ B1000003 /* Order.swift in Sources */,
+ B1000004 /* Payment.swift in Sources */,
+ B1000005 /* OrderHistoryEntry.swift in Sources */,
+ B1000006 /* MerchantApi.swift in Sources */,
+ B1000007 /* ConfigManager.swift in Sources */,
+ B1000014 /* LanguageManager.swift in Sources */,
+ B1000008 /* OrderManager.swift in Sources */,
+ B1000009 /* PaymentManager.swift in Sources */,
+ B100000A /* HistoryManager.swift in Sources */,
+ B100000B /* RefundManager.swift in Sources */,
+ B100000C /* ConfigView.swift in Sources */,
+ B100000D /* OrderView.swift in Sources */,
+ B100000E /* AmountEntryView.swift in Sources */,
+ B100000F /* PaymentView.swift in Sources */,
+ B1000010 /* HistoryView.swift in Sources */,
+ B1000011 /* RefundView.swift in Sources */,
+ B1000012 /* SettingsView.swift in Sources */,
+ B1000015 /* PosQRView.swift in Sources */,
+ B1000016 /* QRScannerView.swift in Sources */,
+ B1000013 /* ScreenshotController.swift in Sources */,
+ C1000003 /* Strings.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D6000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D1000001 /* ConfigProductTests.swift in Sources */,
+ D1000002 /* OrderManagerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E6000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E1000001 /* ScreenshotTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ D3000003 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A5000001 /* TalerPOS */;
+ targetProxy = D3000001 /* PBXContainerItemProxy */;
+ };
+ E3000003 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A5000001 /* TalerPOS */;
+ targetProxy = E3000001 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
A9000001 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -358,6 +620,7 @@
DEVELOPMENT_TEAM = GUDDQ9428Y;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TalerPOS/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Taler POS";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -369,7 +632,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.1.0;
+ MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -396,6 +659,7 @@
DEVELOPMENT_TEAM = GUDDQ9428Y;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TalerPOS/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Taler POS";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -407,7 +671,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.1.0;
+ MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talersale-2";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -420,6 +684,88 @@
};
name = Release;
};
+ D9000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = GUDDQ9428Y;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.TalerPOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TalerPOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TalerPOS";
+ };
+ name = Debug;
+ };
+ D9000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = GUDDQ9428Y;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.TalerPOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TalerPOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TalerPOS";
+ };
+ name = Release;
+ };
+ E9000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = GUDDQ9428Y;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.TalerPOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = TalerPOS;
+ };
+ name = Debug;
+ };
+ E9000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = GUDDQ9428Y;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.TalerPOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = TalerPOS;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -441,6 +787,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ D7000001 /* Build configuration list for PBXNativeTarget "TalerPOSTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D9000001 /* Debug */,
+ D9000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E7000001 /* Build configuration list for PBXNativeTarget "TalerPOSUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E9000001 /* Debug */,
+ E9000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = A8000001 /* Project object */;
diff --git a/TalerPOS.xcodeproj/xcshareddata/xcschemes/TalerPOS.xcscheme b/TalerPOS.xcodeproj/xcshareddata/xcschemes/TalerPOS.xcscheme
@@ -29,6 +29,28 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
+ <Testables>
+ <TestableReference
+ skipped = "NO">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "D5000001"
+ BuildableName = "TalerPOSTests.xctest"
+ BlueprintName = "TalerPOSTests"
+ ReferencedContainer = "container:TalerPOS.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ <TestableReference
+ skipped = "NO">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "E5000001"
+ BuildableName = "TalerPOSUITests.xctest"
+ BlueprintName = "TalerPOSUITests"
+ ReferencedContainer = "container:TalerPOS.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ </Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
diff --git a/TalerPOS/App/ContentView.swift b/TalerPOS/App/ContentView.swift
@@ -1,15 +1,412 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
import SwiftUI
-struct ContentView: View {
+enum PosDestination: Hashable {
+ case amountEntry
+ case order
+ case history
+ case settings
+ case config
+ case processPayment
+ case paymentSuccess
+ case refund(OrderHistoryEntry)
+ case refundUri
+
+ func hash(into hasher: inout Hasher) {
+ switch self {
+ case .amountEntry: hasher.combine("amountEntry")
+ case .order: hasher.combine("order")
+ case .history: hasher.combine("history")
+ case .settings: hasher.combine("settings")
+ case .config: hasher.combine("config")
+ case .processPayment: hasher.combine("processPayment")
+ case .paymentSuccess: hasher.combine("paymentSuccess")
+ case .refund(let item): hasher.combine("refund"); hasher.combine(item.orderId)
+ case .refundUri: hasher.combine("refundUri")
+ }
+ }
+
+ static func == (lhs: PosDestination, rhs: PosDestination) -> Bool {
+ switch (lhs, rhs) {
+ case (.amountEntry, .amountEntry), (.order, .order), (.history, .history),
+ (.settings, .settings), (.config, .config), (.processPayment, .processPayment),
+ (.paymentSuccess, .paymentSuccess), (.refundUri, .refundUri):
+ return true
+ case (.refund(let a), .refund(let b)):
+ return a.orderId == b.orderId
+ default: return false
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .amountEntry: return S.menuAmountEntry
+ case .order: return S.menuOrder
+ case .history: return S.menuHistory
+ case .settings: return S.menuSettings
+ case .config: return S.configLabel
+ case .processPayment: return S.paymentProcessLabel
+ case .paymentSuccess: return S.paymentReceived
+ case .refund: return S.historyRefund
+ case .refundUri: return S.historyRefund
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .amountEntry: return "circle.grid.3x3"
+ case .order: return "cart"
+ case .history: return "clock"
+ case .settings: return "gearshape"
+ default: return "circle"
+ }
+ }
+
+ var isMainDestination: Bool {
+ switch self {
+ case .amountEntry, .order, .history, .settings: return true
+ default: return false
+ }
+ }
+}
+
+struct NavigationDrawer: View {
+ let currentRoot: PosDestination
+ let destinations: [PosDestination]
+ let onSelect: (PosDestination) -> Void
+
var body: some View {
- NavigationStack {
- Text("Taler POS")
- .font(.largeTitle)
- .fontWeight(.bold)
+ VStack(alignment: .leading, spacing: 6) {
+ Image("talerpos-logo")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .frame(height: 56)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
+
+ ForEach(destinations, id: \.self) { dest in
+ Button {
+ onSelect(dest)
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: dest.icon)
+ .font(.body)
+ .frame(width: 24)
+ Text(dest.title)
+ .font(.body)
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 16)
+ .background(
+ currentRoot == dest
+ ? Color.posSecondaryContainer
+ : Color.clear
+ )
+ .foregroundColor(
+ currentRoot == dest
+ ? .posOnSecondaryContainer
+ : .posOnSurface
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 28))
+ }
+ .buttonStyle(.plain)
+ }
+
+ Spacer()
+ }
+ .padding(12)
+ .frame(width: 280)
+ .frame(maxHeight: .infinity)
+ .background(Color.posSurface)
+ }
+}
+
+struct PosTopBar<Actions: View>: View {
+ let title: String
+ let onMenuTap: () -> Void
+ let actions: Actions
+
+ init(title: String, onMenuTap: @escaping () -> Void, @ViewBuilder actions: () -> Actions) {
+ self.title = title
+ self.onMenuTap = onMenuTap
+ self.actions = actions()
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ Button {
+ onMenuTap()
+ } label: {
+ Image(systemName: "line.3.horizontal")
+ .font(.title2)
+ .frame(width: 44, height: 44)
+ .foregroundColor(.posOnSurface)
+ }
+ .buttonStyle(.plain)
+
+ Text(title)
+ .font(.title3)
+ .fontWeight(.medium)
+ .lineLimit(1)
+
+ Spacer(minLength: 0)
+
+ actions
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
}
+ .background(Color.posSurface)
+ .shadow(color: .black.opacity(0.12), radius: 2, y: 1)
+ }
+}
+
+extension PosTopBar where Actions == EmptyView {
+ init(title: String, onMenuTap: @escaping () -> Void) {
+ self.title = title
+ self.onMenuTap = onMenuTap
+ self.actions = EmptyView()
}
}
-#Preview {
- ContentView()
+struct ContentView: View {
+ @EnvironmentObject var configManager: ConfigManager
+ @EnvironmentObject var orderManager: OrderManager
+ @EnvironmentObject var paymentManager: PaymentManager
+ @EnvironmentObject var historyManager: HistoryManager
+ @EnvironmentObject var refundManager: RefundManager
+ @State private var navigationPath = NavigationPath()
+ @State private var currentRoot: PosDestination = .amountEntry
+ @State var drawerOpen = false
+
+ private var mainDestinations: [PosDestination] {
+ let preferred: PosDestination = configManager.initialOrderScreen.destination
+ var items: [PosDestination] = [.amountEntry, .order, .history, .settings]
+ items.removeAll { $0 == preferred }
+ items.insert(preferred, at: 0)
+ return items
+ }
+
+ private var appState: AppState {
+ if !configManager.isValid {
+ return .needsConfig
+ }
+ if configManager.isConfigured {
+ return .configured
+ }
+ return .fetching
+ }
+
+ private enum AppState {
+ case needsConfig
+ case fetching
+ case configured
+ }
+
+ var body: some View {
+ Group {
+ switch appState {
+ case .configured:
+ mainView
+ case .fetching:
+ NavigationStack {
+ configFetcherView
+ }
+ case .needsConfig:
+ NavigationStack {
+ ConfigView(onConfigured: {
+ currentRoot = configManager.initialOrderScreen.destination
+ })
+ }
+ }
+ }
+ .onAppear {
+ if let scenario = ScreenshotController.activeScenario {
+ let dest = scenario.destination
+ if dest.isMainDestination {
+ currentRoot = dest
+ } else {
+ currentRoot = .order
+ DispatchQueue.main.async {
+ navigationPath.append(dest)
+ }
+ }
+ } else {
+ currentRoot = configManager.initialOrderScreen.destination
+ }
+ }
+ .alert(S.sessionExpiredToast, isPresented: $configManager.sessionExpired) {
+ Button(S.configOk) {
+ configManager.sessionExpired = false
+ }
+ } message: {
+ Text(S.sessionExpiredToast)
+ }
+ .onChange(of: refundManager.refundResult) { result in
+ if case .success = result {
+ navigateTo(.refundUri)
+ }
+ }
+ .onChange(of: refundManager.refundReceived) { received in
+ if received {
+ navigationPath.removeLast(navigationPath.count)
+ currentRoot = .history
+ }
+ }
+ .onChange(of: paymentManager.payment?.paid) { paid in
+ guard paid == true else { return }
+ if let order = paymentManager.payment?.order {
+ orderManager.onOrderPaid(order.id)
+ }
+ navigationPath.removeLast(navigationPath.count)
+ navigateTo(.paymentSuccess)
+ }
+ .onChange(of: configManager.isConfigured) { configured in
+ if !configured {
+ navigationPath.removeLast(navigationPath.count)
+ }
+ }
+ .onChange(of: configManager.isValid) { valid in
+ if !valid {
+ navigationPath.removeLast(navigationPath.count)
+ }
+ }
+ }
+
+ private var configFetcherView: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ ProgressView()
+ .scaleEffect(1.5)
+ Text(S.configFetching)
+ .font(.title3)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .navigationTitle(S.configLabel)
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ if configManager.savePassword {
+ configManager.fetchConfig(
+ url: configManager.merchantUrl,
+ token: configManager.accessToken,
+ save: false
+ )
+ }
+ }
+ .onChange(of: configManager.configResult) { result in
+ if case .success = result {
+ currentRoot = configManager.initialOrderScreen.destination
+ }
+ }
+ }
+
+ private var mainView: some View {
+ ZStack(alignment: .leading) {
+ NavigationStack(path: $navigationPath) {
+ rootView(for: currentRoot)
+ .navigationDestination(for: PosDestination.self) { destination in
+ destinationView(for: destination)
+ }
+ }
+
+ if drawerOpen {
+ Color.black.opacity(0.3)
+ .ignoresSafeArea()
+ .onTapGesture {
+ withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = false }
+ }
+ .transition(.opacity)
+ .zIndex(1)
+
+ NavigationDrawer(
+ currentRoot: currentRoot,
+ destinations: mainDestinations,
+ onSelect: { dest in
+ withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = false }
+ currentRoot = dest
+ navigationPath.removeLast(navigationPath.count)
+ }
+ )
+ .transition(.move(edge: .leading))
+ .zIndex(2)
+ }
+ }
+ }
+
+ private func openDrawerAnimated() {
+ withAnimation(.easeInOut(duration: 0.25)) { drawerOpen = true }
+ }
+
+ @ViewBuilder
+ private func rootView(for destination: PosDestination) -> some View {
+ switch destination {
+ case .amountEntry:
+ AmountEntryView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
+ case .order:
+ OrderView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
+ case .history:
+ HistoryView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
+ case .settings:
+ SettingsView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
+ default:
+ OrderView(onNavigate: navigateTo, openDrawer: openDrawerAnimated)
+ }
+ }
+
+ @ViewBuilder
+ private func destinationView(for destination: PosDestination) -> some View {
+ switch destination {
+ case .processPayment:
+ ProcessPaymentView(onNavigate: navigateTo)
+ case .paymentSuccess:
+ PaymentSuccessView(onNavigate: navigateTo)
+ case .refund(let item):
+ RefundFormView(item: item)
+ case .refundUri:
+ RefundUriView()
+ case .config:
+ ConfigView(onConfigured: {
+ navigationPath.removeLast(navigationPath.count)
+ currentRoot = configManager.initialOrderScreen.destination
+ })
+ default:
+ rootView(for: destination)
+ }
+ }
+
+ func navigateTo(_ destination: PosDestination) {
+ if destination.isMainDestination {
+ currentRoot = destination
+ navigationPath.removeLast(navigationPath.count)
+ } else {
+ navigationPath.append(destination)
+ }
+ }
+
+ func navigateBack() {
+ if !navigationPath.isEmpty {
+ navigationPath.removeLast()
+ }
+ }
}
diff --git a/TalerPOS/App/TalerPOSApp.swift b/TalerPOS/App/TalerPOSApp.swift
@@ -1,10 +1,133 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
import SwiftUI
+
@main
struct TalerPOSApp: App {
+ @StateObject private var configManager: ConfigManager
+ @StateObject private var orderManager: OrderManager
+ @StateObject private var paymentManager: PaymentManager
+ @StateObject private var historyManager: HistoryManager
+ @StateObject private var refundManager: RefundManager
+ @ObservedObject private var languageManager = LanguageManager.shared
+
+ init() {
+ let merchantApi = MerchantApi()
+ let config = ConfigManager(api: merchantApi)
+ let order = OrderManager()
+ let payment = PaymentManager(api: merchantApi, configManager: config)
+ let history = HistoryManager(api: merchantApi, configManager: config)
+ let refund = RefundManager(api: merchantApi, configManager: config)
+
+ config.addConfigurationReceiver(order)
+
+ _configManager = StateObject(wrappedValue: config)
+ _orderManager = StateObject(wrappedValue: order)
+ _paymentManager = StateObject(wrappedValue: payment)
+ _historyManager = StateObject(wrappedValue: history)
+ _refundManager = StateObject(wrappedValue: refund)
+
+ merchantApi.onUnauthorized = { [weak config] in
+ Task { @MainActor in
+ config?.forgetPassword()
+ config?.sessionExpired = true
+ }
+ }
+ }
+
+ private func handleSetupUrl(_ url: URL) {
+ guard url.scheme == "taler-pos", let host = url.host, !host.isEmpty else {
+ configManager.configResult = .error("Invalid setup URL: missing scheme or host.")
+ return
+ }
+
+ let pathSegments = url.pathComponents.filter { $0 != "/" }
+ let pathStyleInstance: String? = {
+ guard pathSegments.count >= 2,
+ pathSegments[0].caseInsensitiveCompare("instances") == .orderedSame,
+ !pathSegments[1].isEmpty else { return nil }
+ return pathSegments[1]
+ }()
+
+ let fragment = url.fragment?.trimmingCharacters(in: CharacterSet(charactersIn: "/").union(.whitespaces)) ?? ""
+ let params: [String: String]? = fragment.contains("=")
+ ? Dictionary(uniqueKeysWithValues: fragment.split(separator: "&").compactMap { part in
+ let kv = part.split(separator: "=", maxSplits: 1)
+ guard kv.count == 2 else { return nil }
+ return (String(kv[0]), String(kv[1]).removingPercentEncoding ?? String(kv[1]))
+ })
+ : nil
+
+ let instance = pathStyleInstance ?? params?["username"]
+ let token: String? = {
+ if pathStyleInstance != nil {
+ let decoded = fragment.removingPercentEncoding ?? fragment
+ return decoded.isEmpty ? nil : decoded
+ }
+ return params?["password"]
+ }()
+
+ guard let instance, !instance.isEmpty, let token, !token.isEmpty else {
+ configManager.configResult = .error("Invalid setup URL: missing instance or credentials.")
+ return
+ }
+
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = host
+ components.path = "/instances/\(instance)"
+ guard let merchantUrl = components.url?.absoluteString else {
+ configManager.configResult = .error("Invalid setup URL: could not build merchant URL.")
+ return
+ }
+
+ configManager.savePassword = true
+ configManager.fetchConfig(url: merchantUrl, token: token, save: true)
+ }
+
var body: some Scene {
WindowGroup {
ContentView()
+ .environment(\.locale, languageManager.locale)
+ .id(languageManager.selectedLanguage)
+ .environmentObject(configManager)
+ .environmentObject(orderManager)
+ .environmentObject(paymentManager)
+ .environmentObject(historyManager)
+ .environmentObject(refundManager)
+ .onAppear {
+ if let scenario = ScreenshotController.activeScenario {
+ ScreenshotController.applyFixture(
+ configManager: configManager,
+ orderManager: orderManager,
+ paymentManager: paymentManager,
+ scenario: scenario
+ )
+ } else {
+ configManager.restoreCachedRuntimeConfig()
+ if configManager.isConfigured {
+ configManager.refreshConfigInBackground()
+ }
+ }
+ }
+ .onOpenURL { url in
+ handleSetupUrl(url)
+ }
}
}
}
diff --git a/TalerPOS/Config/ConfigManager.swift b/TalerPOS/Config/ConfigManager.swift
@@ -0,0 +1,420 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+
+private let kBackendApiVersion = "20:0:8"
+private let kMerchantUrl = "merchantUrl"
+private let kAccessToken = "accessToken"
+private let kSavePassword = "savePassword"
+private let kInitialOrderScreen = "initialOrderScreen"
+private let kCachedRuntimeConfig = "cachedRuntimeConfig"
+
+struct LibtoolVersion {
+ let current: Int
+ let revision: Int
+ let age: Int
+
+ static func parse(_ v: String) -> LibtoolVersion? {
+ let parts = v.split(separator: ":")
+ guard parts.count == 3,
+ let c = Int(parts[0]), let r = Int(parts[1]), let a = Int(parts[2]) else { return nil }
+ return LibtoolVersion(current: c, revision: r, age: a)
+ }
+
+ func isCompatible(with other: LibtoolVersion) -> Bool {
+ current - age <= other.current && current >= other.current - other.age
+ }
+}
+
+enum ConfigUpdateResult: Equatable {
+ case success(currency: String)
+ case error(String)
+}
+
+enum InitialOrderScreen: String, CaseIterable {
+ case amountEntry = "amountEntry"
+ case inventory = "inventory"
+
+ var destination: PosDestination {
+ switch self {
+ case .amountEntry: return .amountEntry
+ case .inventory: return .order
+ }
+ }
+}
+
+@MainActor
+protocol ConfigurationReceiver: AnyObject {
+ func onConfigurationReceived(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String?
+ func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String?
+}
+
+extension ConfigurationReceiver {
+ func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? {
+ return onConfigurationReceived(posConfig: posConfig, currency: currency, currencySpec: currencySpec)
+ }
+}
+
+private struct CachedRuntimeConfig: Codable {
+ let posConfig: PosConfig
+ let merchantConfig: MerchantConfig
+ let currency: String
+ let currencySpec: CurrencySpecification?
+}
+
+@MainActor
+class ConfigManager: ObservableObject {
+ @Published var merchantUrl: String
+ @Published var accessToken: String
+ @Published var savePassword: Bool
+
+ @Published var merchantConfig: MerchantConfig?
+ @Published var currency: String?
+ @Published var currencySpec: CurrencySpecification?
+ @Published var configResult: ConfigUpdateResult?
+ @Published var isLoading = false
+ @Published var sessionExpired = false
+ @Published var initialOrderScreen: InitialOrderScreen
+
+ let api: MerchantApi
+ private let defaults = UserDefaults.standard
+ private var configurationReceivers: [ConfigurationReceiver] = []
+ private var inventoryRefreshTask: Task<Void, Never>?
+ private var cachedRuntimeConfig: CachedRuntimeConfig?
+
+ var isConfigured: Bool {
+ merchantConfig != nil && currency != nil
+ }
+
+ var isValid: Bool {
+ !merchantUrl.isEmpty && !accessToken.isEmpty
+ }
+
+ var hasPassword: Bool {
+ !accessToken.isEmpty
+ }
+
+ init(api: MerchantApi) {
+ self.api = api
+ self.merchantUrl = defaults.string(forKey: kMerchantUrl) ?? ""
+ self.accessToken = defaults.string(forKey: kAccessToken) ?? ""
+ self.savePassword = defaults.object(forKey: kSavePassword) != nil ? defaults.bool(forKey: kSavePassword) : true
+ self.initialOrderScreen = InitialOrderScreen(rawValue: defaults.string(forKey: kInitialOrderScreen) ?? "") ?? .amountEntry
+ restoreCachedRuntimeConfig()
+ }
+
+ func addConfigurationReceiver(_ receiver: ConfigurationReceiver) {
+ configurationReceivers.append(receiver)
+ if let cached = cachedRuntimeConfig {
+ _ = receiver.onConfigurationReceived(posConfig: cached.posConfig, currency: cached.currency, currencySpec: cached.currencySpec)
+ }
+ }
+
+ func setInitialOrderScreen(_ screen: InitialOrderScreen) {
+ initialOrderScreen = screen
+ defaults.set(screen.rawValue, forKey: kInitialOrderScreen)
+ }
+
+ func fetchConfig(config: (url: String, token: String), save: Bool) {
+ fetchConfigInternal(url: config.url, token: config.token, save: save, inventoryOnly: false, silent: false)
+ }
+
+ func fetchConfig(url: String, token: String, save: Bool) {
+ fetchConfigInternal(url: url, token: token, save: save, inventoryOnly: false, silent: false)
+ }
+
+ func reloadConfig() {
+ fetchConfigInternal(url: merchantUrl, token: accessToken, save: true, inventoryOnly: false, silent: false)
+ }
+
+ func refreshConfigInBackground() {
+ guard isValid, hasPassword else { return }
+ fetchConfigInternal(url: merchantUrl, token: accessToken, save: false, inventoryOnly: false, silent: true)
+ }
+
+ func refreshInventory() {
+ inventoryRefreshTask?.cancel()
+ inventoryRefreshTask = Task {
+ try? await Task.sleep(nanoseconds: 350_000_000)
+ guard !Task.isCancelled else { return }
+ await MainActor.run {
+ self.fetchConfigInternal(url: self.merchantUrl, token: self.accessToken, save: false, inventoryOnly: true, silent: true)
+ }
+ }
+ }
+
+ private func fetchConfigInternal(url: String, token: String, save: Bool, inventoryOnly: Bool, silent: Bool) {
+ guard !url.isEmpty, !token.isEmpty else { return }
+ if !silent {
+ isLoading = true
+ configResult = nil
+ }
+
+ Task {
+ do {
+ let fullToken = token.hasPrefix("secret-token:") ? token : "secret-token:\(token)"
+ let posConfig = try await api.fetchPosConfig(merchantUrl: url, accessToken: fullToken)
+ let mc = MerchantConfig(baseUrl: url, apiKey: fullToken)
+ let backendConfig = try await api.fetchBackendConfig(merchantConfig: mc)
+
+ if let expected = LibtoolVersion.parse(kBackendApiVersion),
+ let actual = LibtoolVersion.parse(backendConfig.version),
+ !expected.isCompatible(with: actual) {
+ if !silent {
+ self.configResult = .error("Backend API version \(backendConfig.version) is incompatible (expected \(kBackendApiVersion)).")
+ self.isLoading = false
+ }
+ return
+ }
+
+ let spec = backendConfig.currencies?[backendConfig.currency]
+
+ let posConfigWithCachedStock = applyCachedStockData(posConfig)
+
+ for receiver in configurationReceivers {
+ let error: String?
+ if inventoryOnly {
+ error = receiver.onInventoryUpdated(posConfig: posConfigWithCachedStock, currency: backendConfig.currency, currencySpec: spec)
+ } else {
+ error = receiver.onConfigurationReceived(posConfig: posConfigWithCachedStock, currency: backendConfig.currency, currencySpec: spec)
+ }
+ if let error {
+ if !silent {
+ self.configResult = .error(error)
+ self.isLoading = false
+ }
+ return
+ }
+ }
+
+ if save {
+ self.merchantUrl = url
+ self.accessToken = token
+ self.saveConfig()
+ }
+ self.merchantConfig = mc
+ self.currency = backendConfig.currency
+ self.currencySpec = spec
+ self.saveCachedRuntimeConfig(CachedRuntimeConfig(
+ posConfig: posConfigWithCachedStock,
+ merchantConfig: mc,
+ currency: backendConfig.currency,
+ currencySpec: spec
+ ))
+ if !silent {
+ self.configResult = .success(currency: backendConfig.currency)
+ self.isLoading = false
+ }
+
+ await enrichStockDetails(posConfig: posConfig, merchantConfig: mc, currency: backendConfig.currency, currencySpec: spec)
+ } catch let apiError as ApiError where apiError.isUnauthorized {
+ self.forgetPassword()
+ self.sessionExpired = true
+ if !silent {
+ self.configResult = .error("Authentication error: check your access token.")
+ self.isLoading = false
+ }
+ } catch {
+ if !silent {
+ self.configResult = .error(error.localizedDescription)
+ self.isLoading = false
+ }
+ }
+ }
+ }
+
+ // MARK: - MFA Token Exchange
+
+ /// Attempts to fetch a limited access token.
+ /// Throws ChallengeRequiredError if MFA is needed.
+ func fetchLimitedAccessToken(
+ baseUrl: String,
+ username: String,
+ initialSecret: String,
+ challengeIds: [String] = []
+ ) async throws -> String {
+ return try await api.fetchLimitedAccessToken(
+ baseUrl: baseUrl,
+ username: username,
+ initialSecret: initialSecret,
+ duration: .forever,
+ challengeIds: challengeIds
+ )
+ }
+
+ func requestChallenge(baseUrl: String, username: String, challengeId: String) async throws {
+ try await api.requestChallenge(baseUrl: baseUrl, username: username, challengeId: challengeId)
+ }
+
+ func confirmChallenge(baseUrl: String, username: String, challengeId: String, tan: String) async throws {
+ try await api.confirmChallenge(baseUrl: baseUrl, username: username, challengeId: challengeId, tan: tan)
+ }
+
+ func logout() {
+ inventoryRefreshTask?.cancel()
+ merchantUrl = ""
+ accessToken = ""
+ merchantConfig = nil
+ currency = nil
+ currencySpec = nil
+ configResult = nil
+ defaults.removeObject(forKey: kMerchantUrl)
+ defaults.removeObject(forKey: kAccessToken)
+ clearCachedRuntimeConfig()
+ }
+
+ func forgetPassword() {
+ accessToken = ""
+ merchantConfig = nil
+ currency = nil
+ currencySpec = nil
+ defaults.removeObject(forKey: kAccessToken)
+ clearCachedRuntimeConfig()
+ }
+
+ private func saveConfig() {
+ defaults.set(merchantUrl, forKey: kMerchantUrl)
+ if savePassword {
+ defaults.set(accessToken, forKey: kAccessToken)
+ } else {
+ defaults.removeObject(forKey: kAccessToken)
+ }
+ defaults.set(savePassword, forKey: kSavePassword)
+ }
+
+ private func applyCachedStockData(_ posConfig: PosConfig) -> PosConfig {
+ guard let cached = cachedRuntimeConfig else { return posConfig }
+ let cachedByProductId = Dictionary(
+ cached.posConfig.products.compactMap { p -> (String, ConfigProduct)? in
+ guard let pid = p.productId else { return nil }
+ return (pid, p)
+ },
+ uniquingKeysWith: { _, last in last }
+ )
+ var changed = false
+ var products = posConfig.products
+ for i in products.indices {
+ guard let pid = products[i].productId,
+ let cachedProduct = cachedByProductId[pid] else { continue }
+ if cachedProduct.totalSold == nil && cachedProduct.totalLost == nil &&
+ cachedProduct.unitTotalSold == nil && cachedProduct.unitTotalLost == nil { continue }
+ changed = true
+ products[i] = ConfigProduct(
+ id: products[i].id,
+ productId: products[i].productId,
+ productName: products[i].productName,
+ description: products[i].description,
+ descriptionI18n: products[i].descriptionI18n,
+ price: products[i].price,
+ pricesAreNet: products[i].pricesAreNet,
+ location: products[i].location,
+ image: products[i].image,
+ taxes: products[i].taxes,
+ categories: products[i].categories,
+ quantity: products[i].quantity,
+ totalStock: products[i].totalStock,
+ unitTotalStock: products[i].unitTotalStock,
+ totalSold: cachedProduct.totalSold,
+ unitTotalSold: cachedProduct.unitTotalSold,
+ totalLost: cachedProduct.totalLost,
+ unitTotalLost: cachedProduct.unitTotalLost,
+ availableToSell: products[i].availableToSell,
+ remainingStock: products[i].remainingStock,
+ currencyMismatch: products[i].currencyMismatch
+ )
+ }
+ return changed ? PosConfig(categories: posConfig.categories, products: products) : posConfig
+ }
+
+ private func enrichStockDetails(posConfig: PosConfig, merchantConfig: MerchantConfig, currency: String, currencySpec: CurrencySpecification?) async {
+ var enrichedProducts = posConfig.products
+ var changed = false
+ for (i, product) in enrichedProducts.enumerated() {
+ guard let pid = product.productId, product.stockLimit != nil else { continue }
+ do {
+ let detail = try await api.fetchProductDetail(merchantConfig: merchantConfig, productId: pid)
+ enrichedProducts[i] = ConfigProduct(
+ id: product.id,
+ productId: product.productId,
+ productName: product.productName,
+ description: product.description,
+ descriptionI18n: product.descriptionI18n,
+ price: product.price,
+ pricesAreNet: product.pricesAreNet,
+ location: product.location,
+ image: product.image,
+ taxes: product.taxes,
+ categories: product.categories,
+ quantity: product.quantity,
+ totalStock: product.totalStock,
+ unitTotalStock: product.unitTotalStock,
+ totalSold: detail.totalSold ?? product.totalSold,
+ unitTotalSold: detail.unitTotalSold ?? product.unitTotalSold,
+ totalLost: detail.totalLost ?? product.totalLost,
+ unitTotalLost: detail.unitTotalLost ?? product.unitTotalLost,
+ availableToSell: product.availableToSell,
+ remainingStock: product.remainingStock,
+ currencyMismatch: product.currencyMismatch
+ )
+ changed = true
+ } catch {
+ continue
+ }
+ }
+ if changed {
+ let enriched = PosConfig(categories: posConfig.categories, products: enrichedProducts)
+ for receiver in configurationReceivers {
+ _ = receiver.onInventoryUpdated(posConfig: enriched, currency: currency, currencySpec: currencySpec)
+ }
+ self.saveCachedRuntimeConfig(CachedRuntimeConfig(
+ posConfig: enriched,
+ merchantConfig: merchantConfig,
+ currency: currency,
+ currencySpec: currencySpec
+ ))
+ }
+ }
+
+ func restoreCachedRuntimeConfig() {
+ guard isValid, hasPassword else {
+ clearCachedRuntimeConfig()
+ return
+ }
+ guard let data = defaults.data(forKey: kCachedRuntimeConfig) else { return }
+ guard let restored = try? JSONDecoder().decode(CachedRuntimeConfig.self, from: data) else {
+ clearCachedRuntimeConfig()
+ return
+ }
+ cachedRuntimeConfig = restored
+ merchantConfig = restored.merchantConfig
+ currency = restored.currency
+ currencySpec = restored.currencySpec
+ }
+
+ private func saveCachedRuntimeConfig(_ snapshot: CachedRuntimeConfig) {
+ cachedRuntimeConfig = snapshot
+ if let data = try? JSONEncoder().encode(snapshot) {
+ defaults.set(data, forKey: kCachedRuntimeConfig)
+ }
+ }
+
+ private func clearCachedRuntimeConfig() {
+ cachedRuntimeConfig = nil
+ defaults.removeObject(forKey: kCachedRuntimeConfig)
+ }
+}
diff --git a/TalerPOS/Config/LanguageManager.swift b/TalerPOS/Config/LanguageManager.swift
@@ -0,0 +1,67 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+
+/// Manages in-app language override. Changes apply immediately
+/// without requiring an app restart.
+final class LanguageManager: ObservableObject {
+ static let shared = LanguageManager()
+
+ private static let storageKey = "pos_selected_language"
+
+ @Published private(set) var bundle: Bundle = .main
+ @Published private(set) var selectedLanguage: String = ""
+
+ private init() {
+ let saved = UserDefaults.standard.string(forKey: Self.storageKey) ?? ""
+ selectedLanguage = saved
+ bundle = Self.bundle(for: saved)
+ }
+
+ func setLanguage(_ tag: String) {
+ selectedLanguage = tag
+ if tag.isEmpty {
+ UserDefaults.standard.removeObject(forKey: Self.storageKey)
+ } else {
+ UserDefaults.standard.set(tag, forKey: Self.storageKey)
+ }
+ bundle = Self.bundle(for: tag)
+ }
+
+ var locale: Locale {
+ selectedLanguage.isEmpty ? .current : Locale(identifier: selectedLanguage)
+ }
+
+ private static func bundle(for languageTag: String) -> Bundle {
+ guard !languageTag.isEmpty else { return .main }
+
+ if let path = Bundle.main.path(forResource: languageTag, ofType: "lproj"),
+ let lprojBundle = Bundle(path: path) {
+ return lprojBundle
+ }
+
+ let base = languageTag.components(separatedBy: "-").first ?? languageTag
+ if base != languageTag,
+ let path = Bundle.main.path(forResource: base, ofType: "lproj"),
+ let lprojBundle = Bundle(path: path) {
+ return lprojBundle
+ }
+
+ return .main
+ }
+}
diff --git a/TalerPOS/Debug/ScreenshotController.swift b/TalerPOS/Debug/ScreenshotController.swift
@@ -0,0 +1,167 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+enum ScreenshotScenario: String, CaseIterable {
+ case amountEntry = "amount-entry"
+ case order = "order"
+ case payment = "payment"
+ case paymentSuccess = "payment-success"
+
+ var destination: PosDestination {
+ switch self {
+ case .amountEntry: return .amountEntry
+ case .order: return .order
+ case .payment: return .processPayment
+ case .paymentSuccess: return .paymentSuccess
+ }
+ }
+}
+
+@MainActor
+enum ScreenshotController {
+ private static let launchArgument = "SCREENSHOT_SCENARIO"
+ private static let currency = "CHF"
+
+ static var activeScenario: ScreenshotScenario? {
+ guard let value = ProcessInfo.processInfo.environment[launchArgument]
+ ?? parseLaunchArgument() else { return nil }
+ return ScreenshotScenario(rawValue: value)
+ }
+
+ static var isActive: Bool { activeScenario != nil }
+
+ private static func parseLaunchArgument() -> String? {
+ let args = ProcessInfo.processInfo.arguments
+ guard let idx = args.firstIndex(of: "-\(launchArgument)"),
+ idx + 1 < args.count else { return nil }
+ return args[idx + 1]
+ }
+
+ static func applyFixture(
+ configManager: ConfigManager,
+ orderManager: OrderManager,
+ paymentManager: PaymentManager,
+ scenario: ScreenshotScenario
+ ) {
+ let fixture = buildFixture()
+ configManager.merchantUrl = "https://merchant.example.invalid/instances/demo"
+ configManager.accessToken = "screenshot-fixture"
+ configManager.merchantConfig = MerchantConfig(
+ baseUrl: "https://merchant.example.invalid/instances/demo",
+ apiKey: ""
+ )
+ configManager.currency = currency
+
+ _ = orderManager.onConfigurationReceived(
+ posConfig: fixture,
+ currency: currency,
+ currencySpec: nil
+ )
+
+ seedOrder(orderManager: orderManager, productIds: ["coffee", "croissant", "sandwich", "coffee"])
+
+ switch scenario {
+ case .amountEntry, .order:
+ break
+ case .payment:
+ if let liveOrder = orderManager.currentLiveOrder {
+ paymentManager.payment = Payment(
+ order: liveOrder.order,
+ summary: liveOrder.order.summary,
+ currency: currency,
+ origin: .inventory,
+ orderId: "2026-ORD-1042",
+ talerPayUri: "taler://pay/example.invalid/2026-ORD-1042/ZXCVBNM123456"
+ )
+ }
+ case .paymentSuccess:
+ if let liveOrder = orderManager.currentLiveOrder {
+ paymentManager.payment = Payment(
+ order: liveOrder.order,
+ summary: liveOrder.order.summary,
+ currency: currency,
+ origin: .inventory,
+ orderId: "2026-ORD-1042",
+ paid: true
+ )
+ }
+ }
+ }
+
+ private static func seedOrder(orderManager: OrderManager, productIds: [String]) {
+ guard let liveOrder = orderManager.currentLiveOrder else { return }
+ for pid in productIds {
+ if let product = orderManager.products.first(where: { $0.productId == pid }) {
+ liveOrder.addProduct(product)
+ }
+ }
+ }
+
+ private static func imageDataUri(_ name: String) -> String? {
+ guard let url = Bundle.main.url(forResource: name, withExtension: "png"),
+ let data = try? Data(contentsOf: url) else { return nil }
+ return "data:image/png;base64,\(data.base64EncodedString())"
+ }
+
+ private static func buildFixture() -> PosConfig {
+ let categories = [
+ Category(id: 1, name: "Drinks"),
+ Category(id: 2, name: "Bakery"),
+ Category(id: 3, name: "Lunch"),
+ ]
+ let products = [
+ ConfigProduct(
+ id: "coffee",
+ productId: "coffee",
+ productName: "House Coffee",
+ description: "House Coffee",
+ price: Amount(currency: currency, value: 3, fraction: 80000000),
+ image: imageDataUri("coffee"),
+ categories: [1]
+ ),
+ ConfigProduct(
+ id: "tea",
+ productId: "tea",
+ productName: "Herbal Tea",
+ description: "Herbal Tea",
+ price: Amount(currency: currency, value: 3, fraction: 40000000),
+ image: imageDataUri("tea"),
+ categories: [1]
+ ),
+ ConfigProduct(
+ id: "croissant",
+ productId: "croissant",
+ productName: "Butter Croissant",
+ description: "Butter Croissant",
+ price: Amount(currency: currency, value: 2, fraction: 90000000),
+ image: imageDataUri("croissant"),
+ categories: [2]
+ ),
+ ConfigProduct(
+ id: "sandwich",
+ productId: "sandwich",
+ productName: "Veggie Sandwich",
+ description: "Veggie Sandwich",
+ price: Amount(currency: currency, value: 8, fraction: 50000000),
+ image: imageDataUri("sandwich"),
+ categories: [3]
+ ),
+ ]
+ return PosConfig(categories: categories, products: products)
+ }
+}
diff --git a/TalerPOS/Debug/screenshot-products/coffee.png b/TalerPOS/Debug/screenshot-products/coffee.png
Binary files differ.
diff --git a/TalerPOS/Debug/screenshot-products/croissant.png b/TalerPOS/Debug/screenshot-products/croissant.png
Binary files differ.
diff --git a/TalerPOS/Debug/screenshot-products/sandwich.png b/TalerPOS/Debug/screenshot-products/sandwich.png
Binary files differ.
diff --git a/TalerPOS/Debug/screenshot-products/tea.png b/TalerPOS/Debug/screenshot-products/tea.png
Binary files differ.
diff --git a/TalerPOS/History/HistoryManager.swift b/TalerPOS/History/HistoryManager.swift
@@ -0,0 +1,168 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+import os
+
+private let logger = Logger(subsystem: "net.taler.pos", category: "HistoryManager")
+private let pageSize = 20
+private let loadMoreThreshold = 5
+
+@MainActor
+class HistoryManager: ObservableObject {
+ @Published var items: [OrderHistoryEntry] = []
+ @Published var isLoading = false
+ @Published var isLoadingMore = false
+ @Published var errorMessage: String?
+ @Published var forceDeleteOrderId: String?
+
+ private let api: MerchantApi
+ private let configManager: ConfigManager
+ private var loadedItems: [OrderHistoryEntry] = []
+ private var nextOffset: Int64?
+ private var reachedEnd = false
+
+ init(api: MerchantApi, configManager: ConfigManager) {
+ self.api = api
+ self.configManager = configManager
+ }
+
+ func fetchHistory() {
+ fetchHistoryPage(reset: true)
+ }
+
+ func loadMoreHistoryIfNeeded(lastVisibleIndex: Int) {
+ guard !isLoading, !isLoadingMore, !reachedEnd else { return }
+ if items.isEmpty { return }
+ if lastVisibleIndex < items.count - 1 - loadMoreThreshold { return }
+ fetchHistoryPage(reset: false)
+ }
+
+ func deleteOrder(orderId: String) {
+ guard let merchantConfig = configManager.merchantConfig else { return }
+ isLoading = true
+ Task {
+ do {
+ try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId)
+ self.configManager.refreshInventory()
+ self.fetchHistory()
+ } catch {
+ self.isLoading = false
+ self.forceDeleteOrderId = orderId
+ }
+ }
+ }
+
+ func forceDeleteOrder(_ orderId: String) {
+ forceDeleteOrderId = nil
+ isLoading = true
+ guard let merchantConfig = configManager.merchantConfig else { return }
+ Task {
+ do {
+ try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId, force: true)
+ self.configManager.refreshInventory()
+ self.fetchHistory()
+ } catch {
+ self.errorMessage = error.localizedDescription
+ self.isLoading = false
+ }
+ }
+ }
+
+ func clearForceDeletePrompt() {
+ forceDeleteOrderId = nil
+ }
+
+ func clearError() {
+ errorMessage = nil
+ }
+
+ private func fetchHistoryPage(reset: Bool) {
+ guard let merchantConfig = configManager.merchantConfig else {
+ logger.error("fetchHistoryPage: no merchantConfig, bailing")
+ isLoading = false
+ isLoadingMore = false
+ return
+ }
+ if reset {
+ isLoading = true
+ isLoadingMore = false
+ } else {
+ isLoadingMore = true
+ }
+
+ let offset = reset ? nil : nextOffset
+ Task {
+ do {
+ logger.warning("Fetching history reset=\(reset) offset=\(String(describing: offset))")
+ let history = try await api.getOrderHistory(
+ merchantConfig: merchantConfig,
+ limit: -pageSize,
+ offset: offset
+ )
+ logger.warning("Got \(history.orders.count) orders")
+ self.isLoading = false
+ self.isLoadingMore = false
+
+ if reset {
+ self.loadedItems.removeAll()
+ self.reachedEnd = false
+ }
+
+ let existingIds = Set(self.loadedItems.map(\.orderId))
+ let newItems = history.orders.filter { !existingIds.contains($0.orderId) }
+
+ if !newItems.isEmpty {
+ self.loadedItems += newItems
+ self.nextOffset = newItems.last?.rowId ?? self.loadedItems.last?.rowId
+ }
+ if history.orders.count < pageSize || newItems.isEmpty {
+ self.reachedEnd = true
+ }
+ self.items = self.loadedItems
+ logger.warning("Total items: \(self.items.count)")
+
+ await enrichOrders(newItems: newItems, merchantConfig: merchantConfig)
+ } catch {
+ logger.error("History fetch failed: \(error)")
+ self.isLoading = false
+ self.isLoadingMore = false
+ self.errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func enrichOrders(newItems: [OrderHistoryEntry], merchantConfig: MerchantConfig) async {
+ for item in newItems where item.paid {
+ do {
+ let response = try await api.checkOrder(merchantConfig: merchantConfig, orderId: item.orderId)
+ if case .paid(let refunded, let refundPending, let refundAmount) = response {
+ if let idx = self.loadedItems.firstIndex(where: { $0.orderId == item.orderId }) {
+ self.loadedItems[idx].refunded = refunded
+ self.loadedItems[idx].refundPending = refundPending
+ if let ra = refundAmount {
+ self.loadedItems[idx].refundAmount = ra
+ }
+ self.items = self.loadedItems
+ }
+ }
+ } catch {
+ continue
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Model/Amount.swift b/TalerPOS/Model/Amount.swift
@@ -0,0 +1,234 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+enum AmountError: Error {
+ case invalidCurrency
+ case invalidFormat
+ case overflow
+ case underflow
+ case currencyMismatch
+}
+
+struct Amount: Codable, Equatable, Hashable, Comparable {
+ let currency: String
+ let value: UInt64
+ let fraction: UInt32
+
+ static let fractionalBase: UInt32 = 100_000_000
+ static let maxFractionalDigits = 8
+ static let maxValue: UInt64 = (1 << 52)
+ private static let currencyRegex = try! NSRegularExpression(pattern: "^[-_*A-Za-z0-9]{1,12}$")
+
+ static func zero(_ currency: String) -> Amount {
+ Amount(currency: currency, value: 0, fraction: 0)
+ }
+
+ var isZero: Bool {
+ value == 0 && fraction == 0
+ }
+
+ init(currency: String, value: UInt64, fraction: UInt32 = 0) {
+ self.currency = currency
+ self.value = value
+ self.fraction = fraction
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let str = try container.decode(String.self)
+ guard let parsed = Amount.parse(str) else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid amount: \(str)")
+ }
+ self = parsed
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(description)
+ }
+
+ static func isValidCurrency(_ currency: String) -> Bool {
+ let range = NSRange(currency.startIndex..., in: currency)
+ return currencyRegex.firstMatch(in: currency, range: range) != nil
+ }
+
+ static func parse(_ str: String) -> Amount? {
+ let parts = str.split(separator: ":", maxSplits: 1)
+ guard parts.count == 2 else { return nil }
+ let currency = String(parts[0])
+ guard isValidCurrency(currency) else { return nil }
+ let valueParts = parts[1].split(separator: ".", maxSplits: 1)
+ guard let value = UInt64(valueParts[0]) else { return nil }
+ guard value <= maxValue else { return nil }
+ var fraction: UInt32 = 0
+ if valueParts.count == 2 {
+ let fracStr = String(valueParts[1])
+ guard fracStr.count <= maxFractionalDigits else { return nil }
+ guard fracStr.allSatisfy(\.isNumber) else { return nil }
+ var padded = fracStr
+ while padded.count < 8 { padded += "0" }
+ guard let f = UInt32(padded) else { return nil }
+ fraction = f
+ }
+ return Amount(currency: currency, value: value, fraction: fraction)
+ }
+
+ static func fromString(_ currency: String, _ amountStr: String) -> Amount? {
+ let trimmed = amountStr.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { return nil }
+ let valueParts = trimmed.split(separator: ".", maxSplits: 1)
+ guard let intPart = UInt64(valueParts[0]) else { return nil }
+ guard intPart <= maxValue else { return nil }
+ var fraction: UInt32 = 0
+ if valueParts.count == 2 {
+ let fracStr = String(valueParts[1])
+ guard fracStr.count <= maxFractionalDigits else { return nil }
+ guard fracStr.allSatisfy(\.isNumber) else { return nil }
+ var padded = fracStr
+ while padded.count < 8 { padded += "0" }
+ guard let f = UInt32(padded) else { return nil }
+ fraction = f
+ }
+ return Amount(currency: currency, value: intPart, fraction: fraction)
+ }
+
+ func addInputDigit(_ digit: Int, numInputDigits: Int) -> Amount? {
+ let digitCount = numInputDigits > 0 ? numInputDigits : 2
+ let fracBase = Self.pow10(digitCount)
+ let totalUnits = UInt64(value) * UInt64(fracBase) + UInt64(fraction) / (UInt64(Self.fractionalBase) / UInt64(fracBase))
+ let shifted = totalUnits * 10 + UInt64(digit)
+ let newValue = shifted / UInt64(fracBase)
+ guard newValue <= Self.maxValue else { return nil }
+ let newFracSmall = shifted % UInt64(fracBase)
+ let newFraction = UInt32(newFracSmall) * (Self.fractionalBase / UInt32(fracBase))
+ return Amount(currency: currency, value: newValue, fraction: newFraction)
+ }
+
+ // Integer-only keypad digit removal (no floating point)
+ func removeInputDigit(numInputDigits: Int) -> Amount {
+ let digitCount = numInputDigits > 0 ? numInputDigits : 2
+ let fracBase = Self.pow10(digitCount)
+ let totalUnits = UInt64(value) * UInt64(fracBase) + UInt64(fraction) / (UInt64(Self.fractionalBase) / UInt64(fracBase))
+ let shifted = totalUnits / 10
+ let newValue = shifted / UInt64(fracBase)
+ let newFracSmall = shifted % UInt64(fracBase)
+ let newFraction = UInt32(newFracSmall) * (Self.fractionalBase / UInt32(fracBase))
+ return Amount(currency: currency, value: newValue, fraction: newFraction)
+ }
+
+ private static func pow10(_ n: Int) -> UInt32 {
+ var result: UInt32 = 1
+ for _ in 0..<n { result *= 10 }
+ return result
+ }
+
+ static func + (lhs: Amount, rhs: Amount) -> Amount {
+ precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)")
+ var fraction = UInt64(lhs.fraction) + UInt64(rhs.fraction)
+ var value = lhs.value + rhs.value
+ if fraction >= UInt64(fractionalBase) {
+ value += 1
+ fraction -= UInt64(fractionalBase)
+ }
+ precondition(value <= maxValue, "Amount overflow")
+ return Amount(currency: lhs.currency, value: value, fraction: UInt32(fraction))
+ }
+
+ static func - (lhs: Amount, rhs: Amount) -> Amount {
+ precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)")
+ var fraction = Int64(lhs.fraction) - Int64(rhs.fraction)
+ var value = Int64(lhs.value) - Int64(rhs.value)
+ if fraction < 0 {
+ value -= 1
+ fraction += Int64(fractionalBase)
+ }
+ precondition(value >= 0, "Amount underflow")
+ return Amount(currency: lhs.currency, value: UInt64(value), fraction: UInt32(fraction))
+ }
+
+ static func * (lhs: Amount, rhs: Int) -> Amount {
+ var result = Amount.zero(lhs.currency)
+ for _ in 0..<rhs {
+ result = result + lhs
+ }
+ return result
+ }
+
+ static func < (lhs: Amount, rhs: Amount) -> Bool {
+ precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)")
+ if lhs.value != rhs.value { return lhs.value < rhs.value }
+ return lhs.fraction < rhs.fraction
+ }
+
+ static func > (lhs: Amount, rhs: Amount) -> Bool {
+ precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)")
+ if lhs.value != rhs.value { return lhs.value > rhs.value }
+ return lhs.fraction > rhs.fraction
+ }
+}
+
+extension Amount: CustomStringConvertible {
+ var description: String {
+ let fracStr = String(format: "%08u", fraction)
+ let trimmed = fracStr.replacingOccurrences(of: "0+$", with: "", options: .regularExpression)
+ if trimmed.isEmpty {
+ return "\(currency):\(value)"
+ }
+ return "\(currency):\(value).\(trimmed)"
+ }
+
+ var readableAmount: String {
+ readableAmount(numDigits: 2)
+ }
+
+ func readableAmount(numDigits: Int) -> String {
+ let digits = max(numDigits, 0)
+ if digits == 0 { return "\(value) \(currency)" }
+ let fracStr = String(format: "%08u", fraction)
+ let trimmed = String(fracStr.prefix(digits))
+ return "\(value).\(trimmed) \(currency)"
+ }
+
+ func readableAmount(spec: CurrencySpecification?) -> String {
+ guard let spec else { return readableAmount(numDigits: 2) }
+ let maxDigits = spec.displayDigits
+ let minDigits = spec.trailingZeroDigits
+
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.minimumFractionDigits = minDigits
+ formatter.maximumFractionDigits = max(maxDigits, minDigits)
+ formatter.usesGroupingSeparator = true
+
+ let decimalValue = Decimal(value) + Decimal(fraction) / Decimal(Amount.fractionalBase)
+ let formatted = formatter.string(from: decimalValue as NSDecimalNumber) ?? "\(value)"
+
+ if let symbol = spec.symbol {
+ return "\(symbol)\u{00A0}\(formatted)"
+ }
+ return "\(formatted) \(currency)"
+ }
+
+ func amountStr(numDigits: Int = 2) -> String {
+ let digits = max(numDigits, 0)
+ if digits == 0 { return "\(value)" }
+ let fracStr = String(format: "%08u", fraction)
+ let trimmed = String(fracStr.prefix(digits))
+ return "\(value).\(trimmed)"
+ }
+}
diff --git a/TalerPOS/Model/Order.swift b/TalerPOS/Model/Order.swift
@@ -0,0 +1,127 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+struct Order {
+ let id: Int
+ let currency: String
+ let availableCategories: [Int: Category]
+ var products: [ConfigProduct]
+
+ var title: String { String(id) }
+
+ var summary: String {
+ if products.count == 1 { return products[0].description }
+ let catQuantities = getCategoryQuantities()
+ return catQuantities.map { (cat, qty) in
+ "\(qty) x \(cat.localizedName)"
+ }.joined(separator: ", ")
+ }
+
+ var summaryI18n: [String: String]? {
+ if products.count == 1 { return products[0].descriptionI18n }
+ let catQuantities = getCategoryQuantities()
+ var availableLocales: Set<String>?
+ for (cat, _) in catQuantities {
+ guard let nameI18n = cat.nameI18n else { return nil }
+ if availableLocales == nil {
+ availableLocales = Set(nameI18n.keys)
+ } else {
+ availableLocales = availableLocales!.intersection(nameI18n.keys)
+ }
+ if availableLocales?.isEmpty == true { return nil }
+ }
+ guard let locales = availableLocales, !locales.isEmpty else { return nil }
+ var result: [String: String] = [:]
+ for locale in locales {
+ result[locale] = catQuantities.map { (cat, qty) in
+ "\(qty) x \(cat.nameI18n![locale]!)"
+ }.joined(separator: ", ")
+ }
+ return result
+ }
+
+ var total: Amount {
+ var result = Amount.zero(currency)
+ for product in products {
+ result = result + (product.price * product.quantity)
+ }
+ return result
+ }
+
+ func adding(_ product: ConfigProduct) -> Order {
+ var updated = products
+ if let i = updated.firstIndex(where: { $0.id == product.id }) {
+ updated[i].quantity += 1
+ } else {
+ var newProduct = product
+ newProduct.quantity = 1
+ updated.append(newProduct)
+ }
+ return Order(id: id, currency: currency, availableCategories: availableCategories, products: updated)
+ }
+
+ func removing(_ product: ConfigProduct) -> Order {
+ var updated = products
+ guard let i = updated.firstIndex(where: { $0.id == product.id }) else { return self }
+ if updated[i].quantity <= 1 {
+ updated.remove(at: i)
+ } else {
+ updated[i].quantity -= 1
+ }
+ return Order(id: id, currency: currency, availableCategories: availableCategories, products: updated)
+ }
+
+ private func getCategoryQuantities() -> [(Category, Int)] {
+ var result: [(Category, Int)] = []
+ var seen: [Int: Int] = [:]
+ for product in products {
+ guard let catId = product.categories.first,
+ let cat = availableCategories[catId] else { continue }
+ if let idx = seen[catId] {
+ result[idx].1 += product.quantity
+ } else {
+ seen[catId] = result.count
+ result.append((cat, product.quantity))
+ }
+ }
+ return result
+ }
+
+ func toContractTerms(includeProducts: Bool = true) -> ContractTerms {
+ ContractTerms(
+ summary: summary,
+ summaryI18n: summaryI18n,
+ amount: total,
+ products: includeProducts ? products.map { $0.toContractProduct() } : nil
+ )
+ }
+}
+
+struct ContractTerms: Codable {
+ let summary: String
+ let summaryI18n: [String: String]?
+ let amount: Amount
+ let products: [ContractProduct]?
+
+ enum CodingKeys: String, CodingKey {
+ case summary
+ case summaryI18n = "summary_i18n"
+ case amount
+ case products
+ }
+}
diff --git a/TalerPOS/Model/OrderHistoryEntry.swift b/TalerPOS/Model/OrderHistoryEntry.swift
@@ -0,0 +1,161 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+struct Timestamp: Codable, Equatable {
+ let seconds: UInt64
+
+ var date: Date {
+ Date(timeIntervalSince1970: Double(seconds))
+ }
+
+ init(seconds: UInt64) {
+ self.seconds = seconds
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ if let value = try? container.decode(UInt64.self, forKey: .tS) {
+ self.seconds = value
+ } else if let str = try? container.decode(String.self, forKey: .tS), str == "never" {
+ self.seconds = UInt64.max
+ } else {
+ throw DecodingError.dataCorruptedError(forKey: .tS, in: container, debugDescription: "Expected integer or \"never\" for t_s")
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ if seconds == UInt64.max {
+ try container.encode("never", forKey: .tS)
+ } else {
+ try container.encode(seconds, forKey: .tS)
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case tS = "t_s"
+ }
+}
+
+struct OrderHistoryEntry: Codable, Identifiable {
+ let orderId: String
+ let rowId: Int64?
+ let timestamp: Timestamp
+ let amount: Amount
+ let summary: String
+ let paid: Bool
+ let refundable: Bool
+ var refunded: Bool
+ var refundAmount: Amount?
+ var pendingRefundAmount: Amount?
+ var refundPending: Bool
+
+ var id: String { orderId }
+
+ var hasRefund: Bool {
+ refunded || (refundAmount?.isZero == false)
+ }
+
+ var hasPendingRefund: Bool {
+ refundPending || (pendingRefundAmount?.isZero == false)
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case orderId = "order_id"
+ case rowId = "row_id"
+ case timestamp
+ case amount
+ case summary
+ case paid
+ case refundable
+ case refunded
+ case refundAmount = "refund_amount"
+ case refundedAmount = "refunded_amount"
+ case pendingRefundAmount = "pending_refund_amount"
+ case refundPendingAmount = "refund_pending_amount"
+ case refundPending = "refund_pending"
+ }
+
+ init(
+ orderId: String,
+ rowId: Int64? = nil,
+ timestamp: Timestamp,
+ amount: Amount,
+ summary: String,
+ paid: Bool,
+ refundable: Bool,
+ refunded: Bool = false,
+ refundAmount: Amount? = nil,
+ pendingRefundAmount: Amount? = nil,
+ refundPending: Bool = false
+ ) {
+ self.orderId = orderId
+ self.rowId = rowId
+ self.timestamp = timestamp
+ self.amount = amount
+ self.summary = summary
+ self.paid = paid
+ self.refundable = refundable
+ self.refunded = refunded
+ self.refundAmount = refundAmount
+ self.pendingRefundAmount = pendingRefundAmount
+ self.refundPending = refundPending
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ orderId = try container.decode(String.self, forKey: .orderId)
+ rowId = try container.decodeIfPresent(Int64.self, forKey: .rowId)
+ timestamp = try container.decode(Timestamp.self, forKey: .timestamp)
+ amount = try container.decode(Amount.self, forKey: .amount)
+ summary = try container.decode(String.self, forKey: .summary)
+ paid = try container.decode(Bool.self, forKey: .paid)
+ refundable = try container.decode(Bool.self, forKey: .refundable)
+ refunded = try container.decodeIfPresent(Bool.self, forKey: .refunded) ?? false
+ if let ra = try container.decodeIfPresent(Amount.self, forKey: .refundAmount) {
+ refundAmount = ra
+ } else {
+ refundAmount = try container.decodeIfPresent(Amount.self, forKey: .refundedAmount)
+ }
+ if let pra = try container.decodeIfPresent(Amount.self, forKey: .pendingRefundAmount) {
+ pendingRefundAmount = pra
+ } else {
+ pendingRefundAmount = try container.decodeIfPresent(Amount.self, forKey: .refundPendingAmount)
+ }
+ refundPending = try container.decodeIfPresent(Bool.self, forKey: .refundPending) ?? false
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(orderId, forKey: .orderId)
+ try container.encodeIfPresent(rowId, forKey: .rowId)
+ try container.encode(timestamp, forKey: .timestamp)
+ try container.encode(amount, forKey: .amount)
+ try container.encode(summary, forKey: .summary)
+ try container.encode(paid, forKey: .paid)
+ try container.encode(refundable, forKey: .refundable)
+ try container.encode(refunded, forKey: .refunded)
+ try container.encodeIfPresent(refundAmount, forKey: .refundAmount)
+ try container.encodeIfPresent(pendingRefundAmount, forKey: .pendingRefundAmount)
+ try container.encode(refundPending, forKey: .refundPending)
+ }
+}
+
+struct OrderHistory: Codable {
+ let orders: [OrderHistoryEntry]
+}
diff --git a/TalerPOS/Model/Payment.swift b/TalerPOS/Model/Payment.swift
@@ -0,0 +1,34 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+enum PaymentOrigin {
+ case amountEntry
+ case inventory
+}
+
+struct Payment {
+ let order: Order
+ let summary: String
+ let currency: String
+ let origin: PaymentOrigin
+ var orderId: String?
+ var talerPayUri: String?
+ var claimed: Bool = false
+ var paid: Bool = false
+ var error: String?
+}
diff --git a/TalerPOS/Model/PosConfig.swift b/TalerPOS/Model/PosConfig.swift
@@ -0,0 +1,365 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import UIKit
+
+struct MerchantConfig: Codable {
+ let baseUrl: String
+ let apiKey: String
+
+ enum CodingKeys: String, CodingKey {
+ case baseUrl = "base_url"
+ case apiKey = "api_key"
+ }
+
+ func urlFor(_ endpoint: String) -> String {
+ var base = baseUrl
+ if !base.hasSuffix("/") { base += "/" }
+ return base + endpoint
+ }
+
+ func urlForPathComponent(_ prefix: String, segment: String) -> String {
+ var base = baseUrl
+ if !base.hasSuffix("/") { base += "/" }
+ let encoded = segment.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? segment
+ return base + prefix + "/" + encoded
+ }
+}
+
+struct CurrencySpecification: Codable, Equatable {
+ let name: String?
+ let numFractionalInputDigits: Int?
+ let numFractionalNormalDigits: Int?
+ let numFractionalTrailingZeroDigits: Int?
+ let altUnitNames: [String: String]?
+
+ var inputDigits: Int { numFractionalInputDigits ?? 2 }
+ var displayDigits: Int { numFractionalNormalDigits ?? 2 }
+ var trailingZeroDigits: Int { numFractionalTrailingZeroDigits ?? 2 }
+ var symbol: String? { altUnitNames?["0"] }
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case numFractionalInputDigits = "num_fractional_input_digits"
+ case numFractionalNormalDigits = "num_fractional_normal_digits"
+ case numFractionalTrailingZeroDigits = "num_fractional_trailing_zero_digits"
+ case altUnitNames = "alt_unit_names"
+ }
+}
+
+struct PosConfig: Codable {
+ let categories: [Category]
+ let products: [ConfigProduct]
+}
+
+struct Category: Codable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let nameI18n: [String: String]?
+ var selected: Bool = false
+
+ enum CodingKeys: String, CodingKey {
+ case id, name
+ case nameI18n = "name_i18n"
+ }
+
+ init(id: Int, name: String, nameI18n: [String: String]? = nil, selected: Bool = false) {
+ self.id = id
+ self.name = name
+ self.nameI18n = nameI18n
+ self.selected = selected
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ name = try container.decode(String.self, forKey: .name)
+ nameI18n = try container.decodeIfPresent([String: String].self, forKey: .nameI18n)
+ selected = false
+ }
+
+ var localizedName: String {
+ guard let i18n = nameI18n else { return name }
+ let lang = Locale.current.language.languageCode?.identifier ?? "en"
+ return i18n[lang] ?? name
+ }
+
+ static func == (lhs: Category, rhs: Category) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+struct ConfigProduct: Codable, Identifiable, Equatable {
+ let id: String
+ let productId: String?
+ let productName: String?
+ let description: String
+ let descriptionI18n: [String: String]?
+ let price: Amount
+ let pricesAreNet: Bool
+ let location: String?
+ let image: String?
+ let taxes: [Tax]?
+ let categories: [Int]
+ var quantity: Int
+ let totalStock: Int?
+ let unitTotalStock: String?
+ let totalSold: Int?
+ let unitTotalSold: String?
+ let totalLost: Int?
+ let unitTotalLost: String?
+ var availableToSell: Bool
+ var remainingStock: Int?
+ var currencyMismatch: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case productId = "product_id"
+ case productName = "product_name"
+ case description
+ case descriptionI18n = "description_i18n"
+ case price
+ case pricesAreNet = "prices_are_net"
+ case location = "delivery_location"
+ case image
+ case taxes
+ case categories
+ case quantity
+ case totalStock = "total_stock"
+ case unitTotalStock = "unit_total_stock"
+ case totalSold = "total_sold"
+ case unitTotalSold = "unit_total_sold"
+ case totalLost = "total_lost"
+ case unitTotalLost = "unit_total_lost"
+ }
+
+ init(
+ id: String = UUID().uuidString,
+ productId: String? = nil,
+ productName: String? = nil,
+ description: String,
+ descriptionI18n: [String: String]? = nil,
+ price: Amount,
+ pricesAreNet: Bool = false,
+ location: String? = nil,
+ image: String? = nil,
+ taxes: [Tax]? = nil,
+ categories: [Int],
+ quantity: Int = 0,
+ totalStock: Int? = nil,
+ unitTotalStock: String? = nil,
+ totalSold: Int? = nil,
+ unitTotalSold: String? = nil,
+ totalLost: Int? = nil,
+ unitTotalLost: String? = nil,
+ availableToSell: Bool = true,
+ remainingStock: Int? = nil,
+ currencyMismatch: Bool = false
+ ) {
+ self.id = id
+ self.productId = productId
+ self.productName = productName
+ self.description = description
+ self.descriptionI18n = descriptionI18n
+ self.price = price
+ self.pricesAreNet = pricesAreNet
+ self.location = location
+ self.image = image
+ self.taxes = taxes
+ self.categories = categories
+ self.quantity = quantity
+ self.totalStock = totalStock
+ self.unitTotalStock = unitTotalStock
+ self.totalSold = totalSold
+ self.unitTotalSold = unitTotalSold
+ self.totalLost = totalLost
+ self.unitTotalLost = unitTotalLost
+ self.availableToSell = availableToSell
+ self.remainingStock = remainingStock
+ self.currencyMismatch = currencyMismatch
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = UUID().uuidString
+ self.productId = try container.decodeIfPresent(String.self, forKey: .productId)
+ self.productName = try container.decodeIfPresent(String.self, forKey: .productName)
+ self.description = try container.decode(String.self, forKey: .description)
+ self.descriptionI18n = try container.decodeIfPresent([String: String].self, forKey: .descriptionI18n)
+ self.price = try container.decode(Amount.self, forKey: .price)
+ self.pricesAreNet = try container.decodeIfPresent(Bool.self, forKey: .pricesAreNet) ?? false
+ self.location = try container.decodeIfPresent(String.self, forKey: .location)
+ self.image = try container.decodeIfPresent(String.self, forKey: .image)
+ self.taxes = try container.decodeIfPresent([Tax].self, forKey: .taxes)
+ self.categories = try container.decodeIfPresent([Int].self, forKey: .categories) ?? []
+ self.quantity = try container.decodeIfPresent(Int.self, forKey: .quantity) ?? 0
+ self.totalStock = try container.decodeIfPresent(Int.self, forKey: .totalStock)
+ self.unitTotalStock = try container.decodeIfPresent(String.self, forKey: .unitTotalStock)
+ self.totalSold = try container.decodeIfPresent(Int.self, forKey: .totalSold)
+ self.unitTotalSold = try container.decodeIfPresent(String.self, forKey: .unitTotalSold)
+ self.totalLost = try container.decodeIfPresent(Int.self, forKey: .totalLost)
+ self.unitTotalLost = try container.decodeIfPresent(String.self, forKey: .unitTotalLost)
+ self.availableToSell = true
+ self.remainingStock = nil
+ self.currencyMismatch = false
+ }
+
+ var totalPrice: Amount {
+ price * quantity
+ }
+
+ var displayName: String {
+ let sanitizedName = productName?.sanitizedVisibleText
+ let sanitizedDesc = localizedDescription.sanitizedVisibleText
+ return sanitizedName ?? sanitizedDesc
+ }
+
+ var displayDescription: String? {
+ guard let sanitizedName = productName?.sanitizedVisibleText,
+ sanitizedName != localizedDescription.sanitizedVisibleText else { return nil }
+ let desc = localizedDescription.sanitizedVisibleText
+ return desc.isEmpty ? nil : desc
+ }
+
+ var localizedDescription: String {
+ guard let i18n = descriptionI18n else { return description }
+ let lang = Locale.current.language.languageCode?.identifier ?? "en"
+ return i18n[lang] ?? description
+ }
+
+ var stockLimit: Int? {
+ if totalStock == -1 || unitTotalStock == "-1" { return nil }
+ let total: Int?
+ if let ts = totalStock {
+ total = ts
+ } else if let uts = unitTotalStock, let d = Double(uts) {
+ total = Int(d)
+ } else {
+ return nil
+ }
+ guard let t = total else { return nil }
+ let sold: Int
+ if let ts = totalSold {
+ sold = ts
+ } else if let uts = unitTotalSold, let d = Double(uts) {
+ sold = Int(d)
+ } else {
+ sold = 0
+ }
+ let lost: Int
+ if let tl = totalLost {
+ lost = tl
+ } else if let utl = unitTotalLost, let d = Double(utl) {
+ lost = Int(d)
+ } else {
+ lost = 0
+ }
+ return max(t - sold - lost, 0)
+ }
+
+ var stableKey: String {
+ [
+ productId?.trimmingCharacters(in: .whitespaces) ?? "",
+ productName?.trimmingCharacters(in: .whitespaces) ?? "",
+ description.trimmingCharacters(in: .whitespaces),
+ price.currency,
+ String(price.value),
+ String(price.fraction),
+ categories.map(String.init).joined(separator: ","),
+ ].joined(separator: "|")
+ }
+
+ var decodedImage: UIImage? {
+ guard let imageData = image, !imageData.isEmpty else { return nil }
+ let stripped = imageData
+ .replacingOccurrences(of: "data:image/png;base64,", with: "")
+ .replacingOccurrences(of: "data:image/jpeg;base64,", with: "")
+ guard let data = Data(base64Encoded: stripped) else { return nil }
+ return UIImage(data: data)
+ }
+
+ func toContractProduct() -> ContractProduct {
+ ContractProduct(
+ productId: productId,
+ productName: productName ?? description,
+ description: description,
+ descriptionI18n: descriptionI18n?.isEmpty == false ? descriptionI18n : nil,
+ price: price,
+ pricesAreNet: pricesAreNet,
+ quantity: quantity,
+ image: image?.isEmpty == false ? image : nil,
+ taxes: taxes?.isEmpty == false ? taxes : nil
+ )
+ }
+
+ static func == (lhs: ConfigProduct, rhs: ConfigProduct) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+struct Tax: Codable, Hashable {
+ let name: String
+ let tax: Amount
+}
+
+struct ContractProduct: Codable {
+ let productId: String?
+ let productName: String?
+ let description: String
+ let descriptionI18n: [String: String]?
+ let price: Amount
+ let pricesAreNet: Bool
+ let quantity: Int
+ let image: String?
+ let taxes: [Tax]?
+
+ enum CodingKeys: String, CodingKey {
+ case productId = "product_id"
+ case productName = "product_name"
+ case description
+ case descriptionI18n = "description_i18n"
+ case price
+ case pricesAreNet = "prices_are_net"
+ case quantity
+ case image
+ case taxes
+ }
+}
+
+private extension String {
+ var sanitizedVisibleText: String {
+ filter { char in
+ !char.unicodeScalars.contains { scalar in
+ let cat = scalar.properties.generalCategory
+ return cat == .format || cat == .control || cat == .surrogate
+ || cat == .privateUse || cat == .unassigned
+ }
+ }.trimmingCharacters(in: .whitespaces)
+ }
+}
+
+extension CharacterSet {
+ // .urlPathAllowed permits '/', which would split a single segment into multiple path components.
+ static let urlPathComponentAllowed: CharacterSet = {
+ var set = CharacterSet.urlPathAllowed
+ set.remove("/")
+ return set
+ }()
+}
diff --git a/TalerPOS/Networking/MerchantApi.swift b/TalerPOS/Networking/MerchantApi.swift
@@ -0,0 +1,513 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import os
+
+private let logger = Logger(subsystem: "net.taler.pos", category: "MerchantApi")
+
+enum ApiError: Error, LocalizedError {
+ case network(String)
+ case unauthorized
+ case http(Int, String?, Int?)
+ case decodingFailed(String)
+ case invalidURL
+
+ var errorDescription: String? {
+ switch self {
+ case .network(let msg): return msg
+ case .unauthorized: return "Unauthorized (401): check your access token."
+ case .http(let code, let hint, _):
+ if let hint { return "Error \(code): \(hint)" }
+ return "HTTP error (\(code))"
+ case .decodingFailed(let msg): return "Failed to parse response: \(msg)"
+ case .invalidURL: return "Invalid URL"
+ }
+ }
+
+ var isUnauthorized: Bool {
+ switch self {
+ case .unauthorized: return true
+ case .http(401, _, _): return true
+ default: return false
+ }
+ }
+
+ var isInventoryError: Bool {
+ guard case .http(_, let hint, _) = self, let hint else { return false }
+ let lower = hint.lowercased()
+ return lower.contains("inventory") || lower.contains("stock") ||
+ lower.contains("insufficient") || lower.contains("sold out") ||
+ lower.contains("out of stock")
+ }
+
+ var backendCode: Int? {
+ switch self {
+ case .http(_, _, let code): return code
+ default: return nil
+ }
+ }
+
+ var isPermanentClientError: Bool {
+ switch self {
+ case .http(let status, _, _): return (400..<500).contains(status) && status != 401 && status != 408 && status != 429
+ default: return false
+ }
+ }
+}
+
+struct ApiErrorResponse: Codable {
+ let code: Int?
+ let hint: String?
+ let detail: String?
+}
+
+struct PostOrderRequest: Codable {
+ let order: ContractTerms
+ let refundDelay: RelativeTime?
+ let inventoryProducts: [MinimalInventoryProduct]?
+ let createToken: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case order
+ case refundDelay = "refund_delay"
+ case inventoryProducts = "inventory_products"
+ case createToken = "create_token"
+ }
+}
+
+struct RelativeTime: Codable {
+ let dUs: UInt64
+
+ enum CodingKeys: String, CodingKey {
+ case dUs = "d_us"
+ }
+
+ static func hours(_ h: Int) -> RelativeTime {
+ RelativeTime(dUs: UInt64(h) * 3_600_000_000)
+ }
+}
+
+struct MinimalInventoryProduct: Codable {
+ let productId: String
+ let quantity: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case productId = "product_id"
+ case quantity
+ }
+}
+
+struct PostOrderResponse: Codable {
+ let orderId: String
+
+ enum CodingKeys: String, CodingKey {
+ case orderId = "order_id"
+ }
+}
+
+enum CheckPaymentResponse {
+ case unpaid(talerPayUri: String)
+ case claimed
+ case paid(refunded: Bool, refundPending: Bool, refundAmount: Amount?)
+}
+
+extension CheckPaymentResponse: Decodable {
+ enum CodingKeys: String, CodingKey {
+ case orderStatus = "order_status"
+ case talerPayUri = "taler_pay_uri"
+ case paid
+ case refunded
+ case refundPending = "refund_pending"
+ case refundAmount = "refund_amount"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let status = try container.decode(String.self, forKey: .orderStatus)
+ switch status {
+ case "unpaid":
+ let uri = try container.decode(String.self, forKey: .talerPayUri)
+ self = .unpaid(talerPayUri: uri)
+ case "claimed":
+ self = .claimed
+ case "paid":
+ let refunded = try container.decode(Bool.self, forKey: .refunded)
+ let refundPending = try container.decodeIfPresent(Bool.self, forKey: .refundPending) ?? false
+ let refundAmount = try container.decodeIfPresent(Amount.self, forKey: .refundAmount)
+ self = .paid(refunded: refunded, refundPending: refundPending, refundAmount: refundAmount)
+ default:
+ throw DecodingError.dataCorruptedError(forKey: .orderStatus, in: container, debugDescription: "Unknown order_status: \(status)")
+ }
+ }
+}
+
+struct RefundRequest: Codable {
+ let refund: Amount
+ let reason: String
+}
+
+struct RefundResponse: Codable {
+ let talerRefundUri: String
+
+ enum CodingKeys: String, CodingKey {
+ case talerRefundUri = "taler_refund_uri"
+ }
+}
+
+// MARK: - MFA / Token models
+
+enum TanChannel: String, Codable {
+ case sms
+ case email
+}
+
+struct MfaChallenge: Codable, Identifiable {
+ let challengeId: String
+ let tanChannel: TanChannel
+ let tanInfo: String
+
+ var id: String { challengeId }
+
+ enum CodingKeys: String, CodingKey {
+ case challengeId = "challenge_id"
+ case tanChannel = "tan_channel"
+ case tanInfo = "tan_info"
+ }
+}
+
+struct ChallengesResponse: Codable {
+ let challenges: [MfaChallenge]
+ let combiAnd: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case challenges
+ case combiAnd = "combi_and"
+ }
+}
+
+struct TokenRequest: Codable {
+ let scope: String
+ let duration: TokenDuration
+}
+
+struct TokenDuration: Codable {
+ let dUs: String // "forever" or microseconds as string
+
+ static let forever = TokenDuration(dUs: "forever")
+
+ enum CodingKeys: String, CodingKey {
+ case dUs = "d_us"
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ if dUs == "forever" {
+ try container.encode("forever", forKey: .dUs)
+ } else if let num = Int64(dUs) {
+ try container.encode(num, forKey: .dUs)
+ } else {
+ try container.encode(dUs, forKey: .dUs)
+ }
+ }
+}
+
+struct LimitedTokenResponse: Codable {
+ let token: String
+}
+
+struct ChallengeConfirmRequest: Codable {
+ let tan: String
+}
+
+struct ChallengeRequiredError: Error {
+ let challengeResponse: ChallengesResponse
+}
+
+struct MerchantBackendConfigResponse: Codable {
+ let version: String
+ let currency: String
+ let currencies: [String: CurrencySpecification]?
+}
+
+struct ProductDetail: Codable {
+ let totalSold: Int?
+ let totalLost: Int?
+ let unitTotalSold: String?
+ let unitTotalLost: String?
+
+ enum CodingKeys: String, CodingKey {
+ case totalSold = "total_sold"
+ case totalLost = "total_lost"
+ case unitTotalSold = "unit_total_sold"
+ case unitTotalLost = "unit_total_lost"
+ }
+}
+
+class MerchantApi {
+ private let session: URLSession
+ private let decoder: JSONDecoder
+ private let encoder: JSONEncoder
+ var onUnauthorized: (() -> Void)?
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ self.decoder = JSONDecoder()
+ self.encoder = JSONEncoder()
+ }
+
+ func fetchPosConfig(merchantUrl: String, accessToken: String) async throws -> PosConfig {
+ var url = merchantUrl
+ if !url.hasSuffix("/") { url += "/" }
+ url += "private/pos"
+ return try await get(url: url, accessToken: accessToken)
+ }
+
+ func fetchBackendConfig(merchantConfig: MerchantConfig) async throws -> MerchantBackendConfigResponse {
+ return try await get(url: merchantConfig.urlFor("config"), accessToken: merchantConfig.apiKey)
+ }
+
+ func fetchProductDetail(merchantConfig: MerchantConfig, productId: String) async throws -> ProductDetail {
+ let url = merchantConfig.urlForPathComponent("private/products", segment: productId)
+ return try await get(url: url, accessToken: merchantConfig.apiKey)
+ }
+
+ func postOrder(merchantConfig: MerchantConfig, request: PostOrderRequest) async throws -> PostOrderResponse {
+ let url = merchantConfig.urlFor("private/orders")
+ return try await post(url: url, body: request, accessToken: merchantConfig.apiKey)
+ }
+
+ func checkOrder(merchantConfig: MerchantConfig, orderId: String) async throws -> CheckPaymentResponse {
+ let url = merchantConfig.urlForPathComponent("private/orders", segment: orderId)
+ return try await get(url: url, accessToken: merchantConfig.apiKey)
+ }
+
+ func deleteOrder(merchantConfig: MerchantConfig, orderId: String, force: Bool = false) async throws {
+ var urlStr = merchantConfig.urlForPathComponent("private/orders", segment: orderId)
+ if force { urlStr += "?force=yes" }
+ guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
+ var request = URLRequest(url: url)
+ request.httpMethod = "DELETE"
+ request.setValue("Bearer \(merchantConfig.apiKey)", forHTTPHeaderField: "Authorization")
+ let (data, response) = try await session.data(for: request)
+ try checkResponse(response, data: data)
+ }
+
+ func getOrderHistory(merchantConfig: MerchantConfig, limit: Int = -20, offset: Int64? = nil) async throws -> OrderHistory {
+ var urlStr = merchantConfig.urlFor("private/orders") + "?limit=\(limit)"
+ if let offset { urlStr += "&offset=\(offset)" }
+ return try await get(url: urlStr, accessToken: merchantConfig.apiKey)
+ }
+
+ func giveRefund(merchantConfig: MerchantConfig, orderId: String, request: RefundRequest) async throws -> RefundResponse {
+ let url = merchantConfig.urlForPathComponent("private/orders", segment: orderId) + "/refund"
+ return try await post(url: url, body: request, accessToken: merchantConfig.apiKey)
+ }
+
+ // MARK: - MFA Token Exchange
+
+ func fetchLimitedAccessToken(
+ baseUrl: String,
+ username: String,
+ initialSecret: String,
+ duration: TokenDuration = .forever,
+ challengeIds: [String] = []
+ ) async throws -> String {
+ let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
+ let urlStr = "\(baseUrl)/instances/\(encodedUser)/private/token"
+ guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
+
+ let tokenRequest = TokenRequest(scope: "write", duration: duration)
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer secret-token:\(initialSecret)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ if !challengeIds.isEmpty {
+ request.setValue(challengeIds.joined(separator: ","), forHTTPHeaderField: "Taler-Challenge-Ids")
+ }
+ request.httpBody = try encoder.encode(tokenRequest)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw ApiError.network(error.localizedDescription)
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ApiError.network("Invalid response")
+ }
+
+ if httpResponse.statusCode == 202 {
+ let challengeResponse = try decoder.decode(ChallengesResponse.self, from: data)
+ throw ChallengeRequiredError(challengeResponse: challengeResponse)
+ }
+
+ if httpResponse.statusCode == 401 {
+ throw ApiError.unauthorized
+ }
+
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ var hint: String?
+ var backendCode: Int?
+ if let errorResp = try? decoder.decode(ApiErrorResponse.self, from: data) {
+ hint = errorResp.hint ?? errorResp.detail
+ backendCode = errorResp.code
+ }
+ throw ApiError.http(httpResponse.statusCode, hint, backendCode)
+ }
+
+ let tokenResponse = try decoder.decode(LimitedTokenResponse.self, from: data)
+ let token = tokenResponse.token
+ if token.hasPrefix("secret-token:") {
+ return String(token.dropFirst("secret-token:".count))
+ }
+ return token
+ }
+
+ /// POST /instances/{username}/challenge/{challengeId}
+ /// Requests a challenge (sends TAN to user's phone/email)
+ func requestChallenge(baseUrl: String, username: String, challengeId: String) async throws {
+ let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
+ let encodedChallenge = challengeId.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? challengeId
+ let urlStr = "\(baseUrl)/instances/\(encodedUser)/challenge/\(encodedChallenge)"
+ guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = Data("{}".utf8)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw ApiError.network(error.localizedDescription)
+ }
+ try checkResponse(response, data: data)
+ }
+
+ /// POST /instances/{username}/challenge/{challengeId}/confirm
+ /// Confirms a challenge with the TAN code
+ /// HTTP 409 = invalid code, HTTP 429 = rate limited
+ func confirmChallenge(baseUrl: String, username: String, challengeId: String, tan: String) async throws {
+ let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
+ let encodedChallenge = challengeId.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? challengeId
+ let urlStr = "\(baseUrl)/instances/\(encodedUser)/challenge/\(encodedChallenge)/confirm"
+ guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
+
+ let confirmRequest = ChallengeConfirmRequest(tan: tan)
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try encoder.encode(confirmRequest)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw ApiError.network(error.localizedDescription)
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw ApiError.network("Invalid response")
+ }
+
+ switch httpResponse.statusCode {
+ case 200..<300:
+ return // success
+ case 409:
+ throw ApiError.http(409, "Invalid verification code", nil)
+ case 429:
+ throw ApiError.http(429, "Too many attempts. Please request a new code.", nil)
+ default:
+ try checkResponse(response, data: data)
+ }
+ }
+
+ // MARK: - Private
+
+ private func get<T: Decodable>(url: String, accessToken: String) async throws -> T {
+ guard let url = URL(string: url) else { throw ApiError.invalidURL }
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ logger.warning("GET \(url.absoluteString)")
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ logger.error("GET \(url.absoluteString) network error: \(error.localizedDescription)")
+ throw ApiError.network(error.localizedDescription)
+ }
+ if let http = response as? HTTPURLResponse {
+ logger.warning("GET \(url.absoluteString) -> \(http.statusCode)")
+ }
+ try checkResponse(response, data: data)
+ do {
+ return try decoder.decode(T.self, from: data)
+ } catch {
+ logger.error("GET \(url.absoluteString) decode \(String(describing: T.self)) failed: \(error)")
+ throw ApiError.decodingFailed(error.localizedDescription)
+ }
+ }
+
+ private func post<T: Decodable, B: Encodable>(url: String, body: B, accessToken: String) async throws -> T {
+ guard let url = URL(string: url) else { throw ApiError.invalidURL }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try encoder.encode(body)
+ logger.warning("POST \(url.absoluteString)")
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ logger.error("POST \(url.absoluteString) network error: \(error.localizedDescription)")
+ throw ApiError.network(error.localizedDescription)
+ }
+ if let http = response as? HTTPURLResponse {
+ logger.warning("POST \(url.absoluteString) -> \(http.statusCode)")
+ }
+ try checkResponse(response, data: data)
+ do {
+ return try decoder.decode(T.self, from: data)
+ } catch {
+ logger.error("POST \(url.absoluteString) decode \(String(describing: T.self)) failed: \(error)")
+ throw ApiError.decodingFailed(error.localizedDescription)
+ }
+ }
+
+ private func checkResponse(_ response: URLResponse, data: Data? = nil) throws {
+ guard let httpResponse = response as? HTTPURLResponse else { return }
+ switch httpResponse.statusCode {
+ case 200..<300: return
+ case 401:
+ onUnauthorized?()
+ throw ApiError.unauthorized
+ default:
+ var hint: String?
+ var backendCode: Int?
+ if let data {
+ if let errorResp = try? JSONDecoder().decode(ApiErrorResponse.self, from: data) {
+ hint = errorResp.hint ?? errorResp.detail
+ backendCode = errorResp.code
+ }
+ }
+ throw ApiError.http(httpResponse.statusCode, hint, backendCode)
+ }
+ }
+}
diff --git a/TalerPOS/Order/OrderManager.swift b/TalerPOS/Order/OrderManager.swift
@@ -0,0 +1,467 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+
+private let allProductsCategoryId = -1
+private let uncategorizedCategoryId = -2
+private let legacyDefaultCategoryName = "Default"
+
+enum RestartState { case enabled, disabled, undo }
+
+@MainActor
+class LiveOrder: ObservableObject, Identifiable {
+ let id: Int
+ private let currency: String
+ private let productsByCategory: [Category: [ConfigProduct]]
+ private let canAddProductCheck: (ConfigProduct) -> Bool
+ private let onChanged: () -> Void
+
+ @Published var order: Order
+ @Published var restartState: RestartState = .disabled
+ @Published var selectedOrderLine: ConfigProduct?
+ var lastAddedProduct: ConfigProduct?
+ private var undoOrder: Order?
+
+ var availableCategories: [Int: Category] {
+ Dictionary(uniqueKeysWithValues: productsByCategory.keys.map { ($0.id, $0) })
+ }
+
+ var orderTotal: Amount { order.total }
+
+ var modifyOrderAllowed: Bool {
+ restartState != .disabled && selectedOrderLine != nil
+ }
+
+ var increaseOrderAllowed: Bool {
+ guard let selected = selectedOrderLine else { return false }
+ return canAddProductCheck(selected)
+ }
+
+ var selectedProductKey: String? {
+ selectedOrderLine?.id
+ }
+
+ init(
+ id: Int,
+ currency: String,
+ productsByCategory: [Category: [ConfigProduct]],
+ canAddProduct: @escaping (ConfigProduct) -> Bool,
+ onChanged: @escaping () -> Void
+ ) {
+ self.id = id
+ self.currency = currency
+ self.productsByCategory = productsByCategory
+ self.canAddProductCheck = canAddProduct
+ self.onChanged = onChanged
+ self.order = Order(id: id, currency: currency, availableCategories: Dictionary(uniqueKeysWithValues: productsByCategory.keys.map { ($0.id, $0) }), products: [])
+ }
+
+ func addProduct(_ product: ConfigProduct) {
+ guard canAddProductCheck(product) else { return }
+ lastAddedProduct = product
+ order = order.adding(product)
+ restartState = .enabled
+ repairSelection()
+ onChanged()
+ }
+
+ func removeProduct(_ product: ConfigProduct) {
+ order = order.removing(product)
+ restartState = order.products.isEmpty ? .disabled : .enabled
+ repairSelection()
+ onChanged()
+ }
+
+ var isEmpty: Bool { order.products.isEmpty }
+
+ func restartOrUndo() {
+ if restartState == .undo {
+ if let undo = undoOrder {
+ order = undo
+ restartState = .enabled
+ undoOrder = nil
+ }
+ } else {
+ undoOrder = order
+ order = Order(id: id, currency: currency, availableCategories: availableCategories, products: [])
+ restartState = .undo
+ }
+ repairSelection()
+ onChanged()
+ }
+
+ func selectOrderLine(_ product: ConfigProduct?) {
+ selectedOrderLine = product
+ }
+
+ func repairSelection() {
+ let products = order.products
+ if products.isEmpty {
+ selectedOrderLine = nil
+ return
+ }
+ if let lastAdded = lastAddedProduct, products.contains(where: { $0.id == lastAdded.id }) {
+ selectedOrderLine = products.first(where: { $0.id == lastAdded.id })
+ } else if let current = selectedOrderLine, products.contains(where: { $0.id == current.id }) {
+ selectedOrderLine = products.first(where: { $0.id == current.id })
+ } else {
+ selectedOrderLine = products.last
+ }
+ }
+
+ func increaseSelectedOrderLine() {
+ guard let orderLine = selectedOrderLine else { return }
+ addProduct(orderLine)
+ }
+
+ func decreaseSelectedOrderLine() {
+ guard let orderLine = selectedOrderLine else { return }
+ removeProduct(orderLine)
+ }
+}
+
+@MainActor
+class OrderManager: ObservableObject, ConfigurationReceiver {
+ @Published var categories: [Category] = []
+ @Published var products: [ConfigProduct] = []
+ @Published var currentOrderId: Int = 0
+
+ private var currency: String = ""
+ private var currencySpec: CurrencySpecification?
+ private var productsByCategory: [Category: [ConfigProduct]] = [:]
+ private var productsById: [String: ConfigProduct] = [:]
+ private var currentCategory: Category?
+ private var orders: OrderedDictionary<Int, LiveOrder> = OrderedDictionary()
+ private var orderCounter: Int = 0
+
+ var currentLiveOrder: LiveOrder? {
+ orders[currentOrderId]
+ }
+
+ func getOrder(_ orderId: Int) -> LiveOrder? {
+ orders[orderId]
+ }
+
+ func onConfigurationReceived(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? {
+ return applyConfiguration(posConfig: posConfig, currency: currency, currencySpec: currencySpec, resetOrders: true)
+ }
+
+ func onInventoryUpdated(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?) -> String? {
+ return applyConfiguration(posConfig: posConfig, currency: currency, currencySpec: currencySpec, resetOrders: false)
+ }
+
+ private func applyConfiguration(posConfig: PosConfig, currency: String, currencySpec: CurrencySpecification?, resetOrders: Bool) -> String? {
+ let existingProductsByStableKey = Dictionary(
+ productsById.values.map { ($0.stableKey, $0) },
+ uniquingKeysWith: { _, last in last }
+ )
+
+ guard !posConfig.categories.isEmpty else {
+ return "No valid category found."
+ }
+
+ let selectedCategoryId = resetOrders ? allProductsCategoryId : currentCategory?.id
+ let allProductsCategory = Category(id: allProductsCategoryId, name: S.productCategoryAll)
+ let uncategorizedCategory = Category(id: uncategorizedCategoryId, name: S.productCategoryUncategorized)
+ let visibleCategories = posConfig.categories.filter { !isLegacyDefaultCategory($0) }
+ let legacyDefaultCategoryIds = Set(
+ posConfig.categories.filter { isLegacyDefaultCategory($0) }.map { $0.id }
+ )
+
+ productsByCategory.removeAll()
+ productsById.removeAll()
+ productsByCategory[allProductsCategory] = []
+ for cat in visibleCategories {
+ productsByCategory[cat] = []
+ }
+ productsByCategory[uncategorizedCategory] = []
+
+ for product in posConfig.products {
+ let currencyMismatch = product.price.currency != currency
+ let remainingStock = product.stockLimit
+ let preservedId = existingProductsByStableKey[product.stableKey]?.id ?? product.id
+ var p = ConfigProduct(
+ id: preservedId,
+ productId: product.productId,
+ productName: product.productName,
+ description: product.description,
+ descriptionI18n: product.descriptionI18n,
+ price: product.price,
+ pricesAreNet: product.pricesAreNet,
+ location: product.location,
+ image: product.image,
+ taxes: product.taxes,
+ categories: product.categories,
+ quantity: product.quantity,
+ totalStock: product.totalStock,
+ unitTotalStock: product.unitTotalStock,
+ totalSold: product.totalSold,
+ unitTotalSold: product.unitTotalSold,
+ totalLost: product.totalLost,
+ unitTotalLost: product.unitTotalLost,
+ availableToSell: !currencyMismatch && (remainingStock == nil || remainingStock! > 0),
+ remainingStock: remainingStock,
+ currencyMismatch: currencyMismatch
+ )
+
+ productsById[p.id] = p
+ productsByCategory[allProductsCategory]?.append(p)
+
+ if p.categories.isEmpty {
+ productsByCategory[uncategorizedCategory]?.append(p)
+ }
+ for catId in p.categories {
+ if legacyDefaultCategoryIds.contains(catId) {
+ productsByCategory[uncategorizedCategory]?.append(p)
+ continue
+ }
+ if let cat = visibleCategories.first(where: { $0.id == catId }) {
+ productsByCategory[cat]?.append(p)
+ } else {
+ productsByCategory[uncategorizedCategory]?.append(p)
+ }
+ }
+ }
+
+ self.currency = currency
+ self.currencySpec = currencySpec
+
+ var categoryList = [allProductsCategory] + visibleCategories
+ if let uncatProducts = productsByCategory[uncategorizedCategory], !uncatProducts.isEmpty {
+ categoryList.append(uncategorizedCategory)
+ } else {
+ productsByCategory.removeValue(forKey: uncategorizedCategory)
+ }
+
+ let selectedCategory = categoryList.first(where: { $0.id == selectedCategoryId }) ?? allProductsCategory
+ for i in categoryList.indices {
+ categoryList[i].selected = (categoryList[i].id == selectedCategory.id)
+ }
+ currentCategory = selectedCategory
+ categories = categoryList
+ products = getVisibleProducts()
+
+ if resetOrders {
+ orders.removeAll()
+ orderCounter = 0
+ orders[0] = createOrder(0)
+ currentOrderId = 0
+ } else {
+ trimOrdersToStockLimits()
+ }
+ return nil
+ }
+
+ func selectCategory(_ category: Category) {
+ currentCategory = category
+ categories = categories.map { cat in
+ var c = cat
+ c.selected = (cat.id == category.id)
+ return c
+ }
+ updateVisibleProducts()
+ }
+
+ func addProduct(orderId: Int, product: ConfigProduct) {
+ orders[orderId]?.addProduct(product)
+ }
+
+ func nextOrder() {
+ let keys = orders.orderedKeys
+ guard let idx = keys.firstIndex(of: currentOrderId) else { return }
+
+ var nextId: Int?
+ if idx + 1 < keys.count {
+ nextId = keys[idx + 1]
+ }
+ if nextId == nil {
+ orderCounter += 1
+ nextId = orderCounter
+ orders[nextId!] = createOrder(nextId!)
+ }
+
+ let currentOrder = orders[currentOrderId]
+ if currentOrder?.isEmpty == true {
+ orders.removeValue(forKey: currentOrderId)
+ } else {
+ currentOrder?.lastAddedProduct = nil
+ }
+ currentOrderId = nextId!
+ updateVisibleProducts()
+ }
+
+ func previousOrder() {
+ let keys = orders.orderedKeys
+ guard let idx = keys.firstIndex(of: currentOrderId), idx > 0 else { return }
+ let previousId = keys[idx - 1]
+ let currentOrder = orders[currentOrderId]
+ if currentOrder?.isEmpty == true {
+ orders.removeValue(forKey: currentOrderId)
+ } else {
+ currentOrder?.lastAddedProduct = nil
+ }
+ currentOrderId = previousId
+ updateVisibleProducts()
+ }
+
+ func hasPreviousOrder() -> Bool {
+ guard let keys = orders.orderedKeys.first else { return false }
+ return currentOrderId != keys
+ }
+
+ func hasNextOrder() -> Bool {
+ guard let liveOrder = orders[currentOrderId] else { return false }
+ return liveOrder.restartState == .enabled || currentOrderId != orders.orderedKeys.last
+ }
+
+ func deleteCurrentOrder() {
+ let keys = orders.orderedKeys
+ guard let idx = keys.firstIndex(of: currentOrderId) else { return }
+ orders.removeValue(forKey: currentOrderId)
+ let replacementId: Int
+ if orders.isEmpty {
+ orders[currentOrderId] = createOrder(currentOrderId)
+ replacementId = currentOrderId
+ } else if idx < keys.count - 1 {
+ replacementId = keys[idx + 1]
+ } else {
+ replacementId = keys[max(0, idx - 1)]
+ }
+ currentOrderId = replacementId
+ updateVisibleProducts()
+ }
+
+ func onOrderPaid(_ orderId: Int) {
+ if currentOrderId == orderId {
+ if hasPreviousOrder() {
+ previousOrder()
+ } else {
+ nextOrder()
+ }
+ }
+ orders.removeValue(forKey: orderId)
+ updateVisibleProducts()
+ }
+
+ func canAddProduct(_ product: ConfigProduct) -> Bool {
+ guard let stock = remainingStock(product) else { return true }
+ return stock > 0
+ }
+
+ private func remainingStock(_ product: ConfigProduct) -> Int? {
+ guard let stockLimit = productsById[product.id]?.stockLimit ?? product.stockLimit else { return nil }
+ let reserved = orders.values.reduce(0) { total, liveOrder in
+ total + (liveOrder.order.products.first(where: { $0.id == product.id })?.quantity ?? 0)
+ }
+ return max(stockLimit - reserved, 0)
+ }
+
+ private func isLegacyDefaultCategory(_ category: Category) -> Bool {
+ category.name.caseInsensitiveCompare(legacyDefaultCategoryName) == .orderedSame
+ }
+
+ private func createOrder(_ orderId: Int) -> LiveOrder {
+ LiveOrder(
+ id: orderId,
+ currency: currency,
+ productsByCategory: productsByCategory,
+ canAddProduct: { [weak self] product in
+ self?.canAddProduct(product) ?? true
+ },
+ onChanged: { [weak self] in
+ self?.updateVisibleProducts()
+ }
+ )
+ }
+
+ private func getVisibleProducts() -> [ConfigProduct] {
+ guard let cat = currentCategory else { return [] }
+ return (productsByCategory[cat] ?? []).map(decorateProduct)
+ }
+
+ private func updateVisibleProducts() {
+ products = getVisibleProducts()
+ objectWillChange.send()
+ }
+
+ private func decorateProduct(_ product: ConfigProduct) -> ConfigProduct {
+ let remaining = remainingStock(product)
+ var p = product
+ p.availableToSell = !product.currencyMismatch && (remaining == nil || remaining! > 0)
+ p.remainingStock = remaining
+ return p
+ }
+
+ private func trimOrdersToStockLimits() {
+ for liveOrder in orders.values {
+ var modified = false
+ let trimmedProducts: [ConfigProduct] = liveOrder.order.products.compactMap { orderProduct in
+ guard let stockLimit = productsById[orderProduct.id]?.stockLimit else { return orderProduct }
+ if orderProduct.quantity <= stockLimit { return orderProduct }
+ modified = true
+ if stockLimit <= 0 { return nil }
+ var p = orderProduct
+ p.quantity = stockLimit
+ return p
+ }
+ if modified {
+ liveOrder.order = Order(
+ id: liveOrder.order.id,
+ currency: liveOrder.order.currency,
+ availableCategories: liveOrder.order.availableCategories,
+ products: trimmedProducts
+ )
+ liveOrder.repairSelection()
+ }
+ }
+ }
+}
+
+struct OrderedDictionary<Key: Hashable, Value> {
+ private var dict: [Key: Value] = [:]
+ private(set) var orderedKeys: [Key] = []
+
+ var isEmpty: Bool { dict.isEmpty }
+ var count: Int { dict.count }
+ var values: [Value] { orderedKeys.compactMap { dict[$0] } }
+
+ subscript(key: Key) -> Value? {
+ get { dict[key] }
+ set {
+ if let newValue {
+ if dict[key] == nil {
+ orderedKeys.append(key)
+ }
+ dict[key] = newValue
+ } else {
+ dict.removeValue(forKey: key)
+ orderedKeys.removeAll { $0 == key }
+ }
+ }
+ }
+
+ mutating func removeValue(forKey key: Key) {
+ dict.removeValue(forKey: key)
+ orderedKeys.removeAll { $0 == key }
+ }
+
+ mutating func removeAll() {
+ dict.removeAll()
+ orderedKeys.removeAll()
+ }
+}
diff --git a/TalerPOS/Payment/PaymentManager.swift b/TalerPOS/Payment/PaymentManager.swift
@@ -0,0 +1,203 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+
+@MainActor
+class PaymentManager: ObservableObject {
+ @Published var payment: Payment?
+ @Published var deleteNeedsForce: Bool?
+
+ private let api: MerchantApi
+ private let configManager: ConfigManager
+ private var pollingTask: Task<Void, Never>?
+
+ init(api: MerchantApi, configManager: ConfigManager) {
+ self.api = api
+ self.configManager = configManager
+ }
+
+ func createPayment(order: Order, includeProducts: Bool = true, origin: PaymentOrigin = .inventory) {
+ guard let merchantConfig = configManager.merchantConfig else { return }
+
+ payment = Payment(order: order, summary: order.summary, currency: order.currency, origin: origin)
+ deleteNeedsForce = nil
+
+ let inventoryProducts: [MinimalInventoryProduct]
+ if includeProducts {
+ inventoryProducts = order.products.compactMap { product -> MinimalInventoryProduct? in
+ guard let pid = product.productId else { return nil }
+ return MinimalInventoryProduct(productId: pid, quantity: product.quantity)
+ }
+ } else {
+ inventoryProducts = []
+ }
+ let useInventoryProducts = includeProducts && !inventoryProducts.isEmpty
+
+ let request = PostOrderRequest(
+ order: order.toContractTerms(includeProducts: includeProducts && !useInventoryProducts),
+ refundDelay: .hours(1),
+ inventoryProducts: useInventoryProducts ? inventoryProducts : nil,
+ createToken: true
+ )
+
+ Task {
+ do {
+ let response = try await api.postOrder(merchantConfig: merchantConfig, request: request)
+ self.payment?.orderId = response.orderId
+ self.startPolling(orderId: response.orderId)
+ } catch {
+ if let apiError = error as? ApiError, apiError.isInventoryError {
+ configManager.refreshInventory()
+ }
+ self.payment?.error = error.localizedDescription
+ }
+ }
+ }
+
+ func resumePayment(item: OrderHistoryEntry) {
+ if let current = payment,
+ current.orderId == item.orderId,
+ !current.paid,
+ current.error == nil {
+ startPolling(orderId: item.orderId)
+ return
+ }
+
+ let product = ConfigProduct(
+ description: item.summary,
+ price: item.amount,
+ categories: [Int.min],
+ quantity: 1
+ )
+ let order = Order(
+ id: -2,
+ currency: item.amount.currency,
+ availableCategories: [:],
+ products: [product]
+ )
+ payment = Payment(
+ order: order,
+ summary: item.summary,
+ currency: item.amount.currency,
+ origin: .inventory,
+ orderId: item.orderId,
+ paid: item.paid
+ )
+ if !item.paid {
+ startPolling(orderId: item.orderId)
+ }
+ }
+
+ func cancelPayment(error: String? = nil) {
+ stopPolling()
+ guard let merchantConfig = configManager.merchantConfig else {
+ payment?.error = error
+ return
+ }
+ if let payment = payment, !payment.paid, let orderId = payment.orderId {
+ Task {
+ try? await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId)
+ }
+ }
+ payment?.error = error
+ }
+
+ func tryDeleteOrder() {
+ stopPolling()
+ guard let merchantConfig = configManager.merchantConfig,
+ let orderId = payment?.orderId else { return }
+ Task {
+ do {
+ try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId)
+ self.payment?.error = nil
+ self.deleteNeedsForce = false
+ } catch {
+ self.deleteNeedsForce = true
+ }
+ }
+ }
+
+ func forceDeleteOrder() {
+ guard let merchantConfig = configManager.merchantConfig,
+ let orderId = payment?.orderId else { return }
+ Task {
+ do {
+ try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId, force: true)
+ self.payment?.error = nil
+ self.deleteNeedsForce = nil
+ } catch {
+ self.payment?.error = error.localizedDescription
+ }
+ }
+ }
+
+ func clearDeleteNeedsForce() {
+ deleteNeedsForce = nil
+ }
+
+ func reset() {
+ stopPolling()
+ payment = nil
+ deleteNeedsForce = nil
+ }
+
+ private func startPolling(orderId: String) {
+ stopPolling()
+ pollingTask = Task {
+ guard let merchantConfig = configManager.merchantConfig else { return }
+ var transientFailures = 0
+ let maxTransientRetries = 10
+ while !Task.isCancelled {
+ do {
+ let response = try await api.checkOrder(merchantConfig: merchantConfig, orderId: orderId)
+ transientFailures = 0
+ switch response {
+ case .unpaid(let uri):
+ self.payment?.talerPayUri = uri
+ case .claimed:
+ self.payment?.claimed = true
+ case .paid(_, _, _):
+ self.payment?.paid = true
+ self.stopPolling()
+ }
+ if case .paid = response { break }
+ } catch let apiError as ApiError where apiError.isUnauthorized {
+ self.configManager.forgetPassword()
+ self.configManager.sessionExpired = true
+ self.payment?.error = apiError.localizedDescription
+ break
+ } catch let apiError as ApiError where apiError.isPermanentClientError {
+ self.payment?.error = apiError.localizedDescription
+ break
+ } catch {
+ transientFailures += 1
+ if transientFailures >= maxTransientRetries {
+ self.payment?.error = error.localizedDescription
+ break
+ }
+ }
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ }
+ }
+ }
+
+ private func stopPolling() {
+ pollingTask?.cancel()
+ pollingTask = nil
+ }
+}
diff --git a/TalerPOS/Refund/RefundManager.swift b/TalerPOS/Refund/RefundManager.swift
@@ -0,0 +1,143 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+import Combine
+
+enum RefundResult: Equatable {
+ case error(String)
+ case pastDeadline
+ case alreadyRefunded
+ case success(refundUri: String, item: OrderHistoryEntry, amount: Amount, reason: String)
+
+ static func == (lhs: RefundResult, rhs: RefundResult) -> Bool {
+ switch (lhs, rhs) {
+ case (.error(let a), .error(let b)): return a == b
+ case (.pastDeadline, .pastDeadline): return true
+ case (.alreadyRefunded, .alreadyRefunded): return true
+ case (.success(let a1, let a2, let a3, let a4), .success(let b1, let b2, let b3, let b4)):
+ return a1 == b1 && a2.orderId == b2.orderId && a3 == b3 && a4 == b4
+ default: return false
+ }
+ }
+}
+
+@MainActor
+class RefundManager: ObservableObject {
+ @Published var toBeRefunded: OrderHistoryEntry?
+ @Published var refundResult: RefundResult?
+ @Published var pendingRefundOrderId: String?
+ @Published var refundReceived = false
+
+ private let api: MerchantApi
+ private let configManager: ConfigManager
+ private var statusTask: Task<Void, Never>?
+
+ init(api: MerchantApi, configManager: ConfigManager) {
+ self.api = api
+ self.configManager = configManager
+ }
+
+ func startRefund(item: OrderHistoryEntry) {
+ statusTask?.cancel()
+ toBeRefunded = item
+ refundResult = nil
+ pendingRefundOrderId = nil
+ refundReceived = false
+ }
+
+ func abortRefund() {
+ statusTask?.cancel()
+ toBeRefunded = nil
+ refundResult = nil
+ pendingRefundOrderId = nil
+ refundReceived = false
+ }
+
+ func completeRefund() {
+ statusTask?.cancel()
+ toBeRefunded = nil
+ refundResult = nil
+ pendingRefundOrderId = nil
+ refundReceived = false
+ }
+
+ func resumeRefund(item: OrderHistoryEntry) -> Bool {
+ guard let current = refundResult,
+ case .success(_, let successItem, _, _) = current,
+ successItem.orderId == item.orderId else {
+ return false
+ }
+ toBeRefunded = item
+ pendingRefundOrderId = item.orderId
+ if !refundReceived {
+ observeRefundStatus(orderId: item.orderId)
+ }
+ return true
+ }
+
+ func refund(item: OrderHistoryEntry, amount: Amount, reason: String) {
+ guard let merchantConfig = configManager.merchantConfig else { return }
+ let request = RefundRequest(refund: amount, reason: reason)
+
+ Task {
+ do {
+ let response = try await api.giveRefund(
+ merchantConfig: merchantConfig,
+ orderId: item.orderId,
+ request: request
+ )
+ self.refundResult = .success(
+ refundUri: response.talerRefundUri,
+ item: item,
+ amount: amount,
+ reason: reason
+ )
+ self.pendingRefundOrderId = item.orderId
+ self.refundReceived = false
+ self.observeRefundStatus(orderId: item.orderId)
+ } catch {
+ self.statusTask?.cancel()
+ self.pendingRefundOrderId = nil
+ self.refundReceived = false
+ if let apiError = error as? ApiError, apiError.backendCode == 2602 {
+ self.refundResult = .alreadyRefunded
+ } else {
+ self.refundResult = .error(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+ private func observeRefundStatus(orderId: String) {
+ statusTask?.cancel()
+ statusTask = Task {
+ guard let merchantConfig = configManager.merchantConfig else { return }
+ while !Task.isCancelled {
+ do {
+ let response = try await api.checkOrder(merchantConfig: merchantConfig, orderId: orderId)
+ if case .paid(let refunded, let refundPending, let refundAmount) = response {
+ if refunded && !refundPending && refundAmount?.isZero == false {
+ self.refundReceived = true
+ break
+ }
+ }
+ } catch {}
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -1,6 +1,33 @@
{
"colors" : [
{
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.702",
+ "green" : "0.259",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.773",
+ "red" : "0.706"
+ }
+ },
"idiom" : "universal"
}
],
diff --git a/TalerPOS/Resources/Assets.xcassets/PosBackground.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.992",
+ "red" : "0.992"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.102",
+ "green" : "0.075",
+ "red" : "0.067"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosError.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosError.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.118",
+ "green" : "0.149",
+ "red" : "0.702"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.667",
+ "green" : "0.706",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosErrorContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosErrorContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.863",
+ "green" : "0.871",
+ "red" : "0.976"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.118",
+ "green" : "0.149",
+ "red" : "0.702"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnBackground.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.122",
+ "green" : "0.110",
+ "red" : "0.102"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.922",
+ "green" : "0.886",
+ "red" : "0.886"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnError.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnError.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.012",
+ "green" : "0.000",
+ "red" : "0.412"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnErrorContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnErrorContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.043",
+ "green" : "0.055",
+ "red" : "0.255"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.769",
+ "green" : "0.796",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnPrimary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnPrimary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.471",
+ "green" : "0.165",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnPrimaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnPrimaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.290",
+ "green" : "0.075",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.922",
+ "red" : "0.898"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnSecondary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnSecondary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.365",
+ "green" : "0.192",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnSecondaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnSecondaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.169",
+ "green" : "0.110",
+ "red" : "0.067"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.412",
+ "green" : "0.220",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnSurface.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnSurface.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.122",
+ "green" : "0.106",
+ "red" : "0.110"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.882",
+ "green" : "0.886",
+ "red" : "0.898"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnSurfaceVariant.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnSurfaceVariant.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.310",
+ "green" : "0.278",
+ "red" : "0.271"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.796",
+ "green" : "0.776",
+ "red" : "0.776"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnTertiary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnTertiary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.255",
+ "green" : "0.212",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOnTertiaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOnTertiaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.224",
+ "green" : "0.110",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.961",
+ "green" : "0.878",
+ "red" : "0.612"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOutline.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOutline.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.502",
+ "green" : "0.471",
+ "red" : "0.463"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.584",
+ "green" : "0.565",
+ "red" : "0.561"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosOutlineVariant.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosOutlineVariant.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.816",
+ "green" : "0.776",
+ "red" : "0.769"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.294",
+ "green" : "0.278",
+ "red" : "0.271"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosPrimary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosPrimary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.702",
+ "green" : "0.259",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.773",
+ "red" : "0.706"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosPrimaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosPrimaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.871",
+ "red" : "0.827"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.702",
+ "green" : "0.259",
+ "red" : "0.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosSecondary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosSecondary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.533",
+ "green" : "0.416",
+ "red" : "0.345"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.788",
+ "red" : "0.643"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosSecondaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosSecondaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.976",
+ "green" : "0.890",
+ "red" : "0.851"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.898",
+ "green" : "0.639",
+ "red" : "0.447"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosSurface.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosSurface.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.992",
+ "red" : "0.992"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.102",
+ "green" : "0.075",
+ "red" : "0.067"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosSurfaceVariant.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosSurfaceVariant.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.925",
+ "green" : "0.890",
+ "red" : "0.878"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.294",
+ "green" : "0.278",
+ "red" : "0.271"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosTertiary.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosTertiary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.941",
+ "green" : "0.541",
+ "red" : "0.200"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.898",
+ "green" : "0.820",
+ "red" : "0.553"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/PosTertiaryContainer.colorset/Contents.json b/TalerPOS/Resources/Assets.xcassets/PosTertiaryContainer.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.894",
+ "red" : "0.820"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.467",
+ "green" : "0.396",
+ "red" : "0.086"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/taler-logo-qr.imageset/Contents.json b/TalerPOS/Resources/Assets.xcassets/taler-logo-qr.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "taler-logo-qr.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/taler-logo-qr.imageset/taler-logo-qr.svg b/TalerPOS/Resources/Assets.xcassets/taler-logo-qr.imageset/taler-logo-qr.svg
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+-->
+<svg viewBox="0 0 200 95" xmlns="http://www.w3.org/2000/svg">
+ <clipPath id="clip">
+ <rect x="0" y="0" width="200" height="95"/>
+ </clipPath>
+ <g clip-path="url(#clip)">
+ <path d="M47.5,0L152.5,0A47.5,47.5 0,0 1,200 47.5L200,47.5A47.5,47.5 0,0 1,152.5 95L47.5,95A47.5,47.5 0,0 1,0 47.5L0,47.5A47.5,47.5 0,0 1,47.5 0z" fill="#ffffff"/>
+ <path d="M88.62,8C102.94,8 115.23,16.62 121.65,29.28H116.24C113.46,24.39 109.46,20.3 104.61,17.44C99.76,14.57 94.25,13.02 88.62,12.95C70.55,12.95 55.87,28.64 55.87,48C55.87,57.54 59.36,66.07 65.05,72.4C63.84,73.43 62.55,74.35 61.19,75.16C54.74,67.59 51.22,57.95 51.28,48C51.28,25.98 67.98,8 88.62,8ZM121.47,66.99C118.27,73.65 113.15,79.19 106.78,82.92C100.4,86.64 93.06,88.38 85.69,87.91C88.44,86.53 91.19,84.7 93.58,82.68C98.25,81.86 102.69,80.03 106.59,77.32C110.49,74.62 113.75,71.09 116.15,66.99H121.47Z" fill="#0042B3" fill-rule="evenodd"/>
+ <path d="M67.98,8L70.83,8.09C68.07,9.56 65.41,11.3 63.03,13.41C55.07,14.99 47.93,19.32 42.86,25.65C37.79,31.98 35.12,39.89 35.32,48C35.32,61.12 42.02,72.5 51.83,78.55C49.26,78.99 46.63,79.05 44.04,78.73C35.78,71.39 30.64,60.39 30.64,48C30.64,25.98 47.34,8 67.98,8ZM72.94,82.68C77.61,81.86 82.05,80.03 85.95,77.32C89.85,74.62 93.11,71.09 95.5,66.99H100.92C97.72,73.65 92.6,79.19 86.22,82.92C79.85,86.64 72.51,88.38 65.14,87.91C67.89,86.53 70.55,84.7 72.94,82.68ZM95.69,29.28C92.86,24.48 88.89,20.45 84.13,17.54C86.71,17.11 89.33,17.04 91.93,17.36C95.6,20.66 98.72,24.7 101.01,29.28H95.69Z" fill="#0042B3" fill-rule="evenodd"/>
+ <path d="M47.43,8C48.35,8 49.27,8 50.28,8.18C47.52,9.56 44.86,11.3 42.48,13.32C34.49,14.88 27.32,19.22 22.23,25.57C17.14,31.92 14.46,39.87 14.68,48C14.68,67.36 29.36,83.14 47.43,83.14C58.99,83.14 69.08,76.72 74.95,66.99H80.28C77.31,73.22 72.66,78.49 66.86,82.22C61.05,85.94 54.33,87.98 47.43,88.09C26.79,88.09 10,70.11 10,48C10,25.98 26.7,8 47.34,8H47.43ZM75.05,29.28C73.86,27.29 72.48,25.41 70.92,23.69C72.11,22.59 73.39,21.67 74.86,20.94C76.97,23.41 78.9,26.26 80.37,29.28H75.05Z" fill="#0042B3" fill-rule="evenodd"/>
+ <path d="M78.9,38.55H87.34V33.96H65.87V38.55H74.22V62.4H78.9V38.55ZM94.04,55.52H106.61L109.36,62.31H114.22L102.57,33.69H98.26L86.61,62.31H91.38L94.04,55.52ZM104.86,51.03H95.78L100.37,39.65L104.86,51.03ZM122.66,33.96H118.44V62.4H137.34V57.82H122.66V33.96ZM161.84,33.96H142.11V62.4H161.93V57.82H146.7V50.2H160V45.71H146.7V38.37H161.74L161.84,33.96ZM184.5,43.23C184.5,44.7 184.04,45.8 183.03,46.72C182.02,47.63 180.64,48 178.99,48H172.2V38.46H178.99C180.73,38.46 182.11,38.83 183.03,39.65C183.95,40.48 184.5,41.67 184.5,43.23ZM190,62.31L182.94,51.58C183.85,51.3 184.68,50.94 185.41,50.39C186.96,49.4 188.13,47.91 188.72,46.17C189.08,45.25 189.17,44.15 189.17,42.95C189.17,41.58 188.99,40.29 188.53,39.19C188.11,38.11 187.45,37.14 186.61,36.35C185.69,35.61 184.77,34.97 183.49,34.51C182.29,34.15 180.92,33.96 179.36,33.96H167.52V62.4H172.11V52.31H178.07L184.5,62.22L190,62.31Z" fill="#000000"/>
+ </g>
+</svg>
diff --git a/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/Contents.json b/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "talerpos-logo.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/talerpos-logo.svg b/TalerPOS/Resources/Assets.xcassets/talerpos-logo.imageset/talerpos-logo.svg
@@ -0,0 +1,33 @@
+<svg width="1706" height="521" viewBox="0 0 1706 521" fill="none" xmlns="http://www.w3.org/2000/svg">
+<!--
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+-->
+<g clip-path="url(#clip0_410_228)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M503.795 6.49023C594.153 6.49023 672.628 60.9124 712.226 140.803H678.508C641.882 78.8635 577.329 37.7322 503.795 37.7322C389.668 37.7322 297.15 136.792 297.15 258.987C297.15 318.796 319.324 373.055 355.34 412.873C347.56 419.361 339.276 425.191 330.559 430.278C291.715 385.27 267.971 325.103 267.971 258.987C267.971 119.537 373.553 6.49023 503.795 6.49023ZM711.594 378.454C671.796 457.646 593.67 511.484 503.795 511.484C497.694 511.484 491.646 511.235 485.661 510.748C503.352 501.711 519.937 490.601 535.149 477.709C594.97 467.958 646.352 430.74 677.742 378.454H711.594Z" fill="#0042B3"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M373.676 6.49023C379.777 6.49023 385.824 6.73853 391.81 7.22583C374.12 16.2632 357.534 27.373 342.321 40.2646C243.075 56.4407 167.031 148.204 167.031 258.987C167.031 341.563 209.288 413.562 271.904 451.577C262.667 453.124 253.2 453.933 243.557 453.933C236.372 453.933 229.29 453.472 222.323 452.605C170.692 406.287 137.852 336.749 137.852 258.987C137.852 119.537 243.434 6.49023 373.676 6.49023ZM405.031 477.709C464.853 467.958 516.234 430.739 547.625 378.451H581.477C541.679 457.645 463.553 511.484 373.676 511.484C367.575 511.484 361.527 511.235 355.542 510.748C373.231 501.71 389.819 490.602 405.031 477.709ZM548.389 140.803C530.192 110.037 505.106 84.4031 475.448 66.3967C484.686 64.8499 494.151 64.0408 503.796 64.0408C510.979 64.0408 518.062 64.5012 525.029 65.3687C548.307 86.2507 567.765 111.854 582.115 140.803H548.389Z" fill="#0042B3"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M243.556 6.49023C249.725 6.49023 255.837 6.74707 261.887 7.2439C244.229 16.2686 227.672 27.3577 212.484 40.2222C113.1 56.2712 36.9116 148.1 36.9116 258.987C36.9116 381.182 129.43 480.241 243.556 480.241C316.584 480.241 380.762 439.678 417.517 378.451H451.357C411.559 457.645 333.433 511.484 243.556 511.484C113.314 511.484 7.73242 398.437 7.73242 258.987C7.73242 119.537 113.314 6.49023 243.556 6.49023ZM418.264 140.803C410.661 127.948 401.856 115.986 392.015 105.106C399.796 98.6167 408.078 92.7847 416.794 87.697C430.547 103.634 442.406 121.473 451.99 140.803H418.264Z" fill="#0042B3"/>
+<path d="M442.782 199.19H495.88V169.995H360.027V199.19H413.126V349.256H442.782V199.19Z" fill="black"/>
+<path d="M538.487 305.98H617.655L634.638 349.258H665.731L592.063 168.717H564.799L491.131 349.258H521.268L538.487 305.98ZM606.893 278.064H549.252L577.953 206.36L606.893 278.064Z" fill="black"/>
+<path d="M719.083 169.997H692.535V349.258H811.628V320.833C780.78 320.833 749.932 320.833 719.083 320.833V169.997Z" fill="black"/>
+<path d="M966.376 169.997H842.482V349.258H967.572V320.833H871.662V272.943H955.613V244.518H871.662V198.422H966.376V169.997Z" fill="black"/>
+<path d="M1109.64 228.514C1109.64 237.773 1106.53 245.159 1100.28 250.621C1094.06 256.128 1085.65 258.858 1075.08 258.858H1031.91V198.422H1074.84C1085.88 198.422 1094.45 200.941 1100.52 206.019C1106.61 211.056 1109.64 218.567 1109.64 228.514ZM1144.8 349.258L1099.6 281.393C1105.5 279.687 1110.88 277.255 1115.74 274.096C1120.6 270.938 1124.79 267.096 1128.3 262.573C1131.81 258.047 1134.56 252.841 1136.55 246.952C1138.54 241.062 1139.54 234.36 1139.54 226.848C1139.54 218.141 1138.1 210.246 1135.23 203.16C1132.36 196.075 1128.26 190.099 1122.92 185.233C1117.58 180.368 1111.04 176.612 1103.31 173.965C1095.57 171.318 1086.92 169.997 1077.35 169.997H1002.73V349.258H1031.91V286.773H1068.86L1110.12 349.258H1144.8Z" fill="black"/>
+<path d="M1215.18 352V167.4H1291.1C1298.9 167.4 1306.01 169.047 1312.42 172.34C1319.01 175.633 1324.73 180.053 1329.58 185.6C1334.43 190.973 1338.16 197.04 1340.76 203.8C1343.53 210.56 1344.92 217.493 1344.92 224.6C1344.92 234.48 1342.67 243.84 1338.16 252.68C1333.83 261.347 1327.76 268.453 1319.96 274C1312.33 279.547 1303.23 282.32 1292.66 282.32H1228.44V352H1215.18ZM1228.44 270.1H1292.14C1300.29 270.1 1307.31 267.933 1313.2 263.6C1319.09 259.267 1323.6 253.633 1326.72 246.7C1329.84 239.767 1331.4 232.4 1331.4 224.6C1331.4 216.453 1329.49 209 1325.68 202.24C1322.04 195.307 1317.1 189.76 1310.86 185.6C1304.79 181.44 1298.03 179.36 1290.58 179.36H1228.44V270.1ZM1450.24 353.3C1437.42 353.3 1425.72 350.7 1415.14 345.5C1404.57 340.127 1395.47 333.02 1387.84 324.18C1380.22 315.34 1374.32 305.373 1370.16 294.28C1366 283.013 1363.92 271.487 1363.92 259.7C1363.92 247.393 1366.09 235.693 1370.42 224.6C1374.76 213.333 1380.82 203.367 1388.62 194.7C1396.6 185.86 1405.78 178.927 1416.18 173.9C1426.76 168.7 1438.2 166.1 1450.5 166.1C1463.5 166.1 1475.2 168.787 1485.6 174.16C1496.18 179.533 1505.28 186.727 1512.9 195.74C1520.53 204.753 1526.42 214.807 1530.58 225.9C1534.74 236.993 1536.82 248.26 1536.82 259.7C1536.82 272.18 1534.66 284.053 1530.32 295.32C1525.99 306.413 1519.84 316.38 1511.86 325.22C1504.06 333.887 1494.88 340.733 1484.3 345.76C1473.9 350.787 1462.55 353.3 1450.24 353.3ZM1377.18 259.7C1377.18 270.273 1379 280.413 1382.64 290.12C1386.28 299.827 1391.31 308.493 1397.72 316.12C1404.31 323.747 1412.02 329.813 1420.86 334.32C1429.88 338.827 1439.76 341.08 1450.5 341.08C1461.42 341.08 1471.39 338.74 1480.4 334.06C1489.42 329.38 1497.13 323.14 1503.54 315.34C1509.96 307.54 1514.81 298.873 1518.1 289.34C1521.57 279.633 1523.3 269.753 1523.3 259.7C1523.3 249.127 1521.48 238.987 1517.84 229.28C1514.38 219.573 1509.35 210.907 1502.76 203.28C1496.18 195.653 1488.38 189.587 1479.36 185.08C1470.52 180.573 1460.9 178.32 1450.5 178.32C1439.41 178.32 1429.36 180.66 1420.34 185.34C1411.33 190.02 1403.62 196.26 1397.2 204.06C1390.96 211.86 1386.02 220.613 1382.38 230.32C1378.92 239.853 1377.18 249.647 1377.18 259.7ZM1679.81 198.08C1676.86 194.787 1673.57 191.927 1669.93 189.5C1666.29 186.9 1662.39 184.82 1658.23 183.26C1654.07 181.527 1649.56 180.227 1644.71 179.36C1639.85 178.493 1634.65 178.06 1629.11 178.06C1611.08 178.06 1597.91 181.527 1589.59 188.46C1581.44 195.22 1577.37 204.407 1577.37 216.02C1577.37 223.82 1579.19 230.06 1582.83 234.74C1586.64 239.247 1592.53 242.887 1600.51 245.66C1608.48 248.433 1618.79 251.12 1631.45 253.72C1644.79 256.493 1656.32 259.787 1666.03 263.6C1675.73 267.24 1683.19 272.267 1688.39 278.68C1693.76 285.093 1696.45 293.76 1696.45 304.68C1696.45 312.827 1694.89 320.02 1691.77 326.26C1688.65 332.327 1684.14 337.353 1678.25 341.34C1672.35 345.327 1665.33 348.36 1657.19 350.44C1649.21 352.52 1640.29 353.56 1630.41 353.56C1620.87 353.56 1611.77 352.52 1603.11 350.44C1594.44 348.36 1586.29 345.327 1578.67 341.34C1571.04 337.353 1563.93 332.24 1557.35 326L1564.37 315.34C1568.18 319.5 1572.43 323.227 1577.11 326.52C1581.96 329.64 1587.16 332.327 1592.71 334.58C1598.43 336.833 1604.49 338.653 1610.91 340.04C1617.32 341.253 1623.99 341.86 1630.93 341.86C1647.05 341.86 1659.61 338.913 1668.63 333.02C1677.81 326.953 1682.41 318.027 1682.41 306.24C1682.41 298.093 1680.33 291.507 1676.17 286.48C1672.01 281.453 1665.59 277.38 1656.93 274.26C1648.26 270.967 1637.43 267.933 1624.43 265.16C1611.43 262.387 1600.33 259.267 1591.15 255.8C1582.13 252.333 1575.29 247.653 1570.61 241.76C1566.1 235.867 1563.85 227.893 1563.85 217.84C1563.85 206.747 1566.53 197.387 1571.91 189.76C1577.45 181.96 1585.17 176.067 1595.05 172.08C1604.93 168.093 1616.28 166.1 1629.11 166.1C1637.08 166.1 1644.45 166.88 1651.21 168.44C1658.14 170 1664.47 172.427 1670.19 175.72C1676.08 178.84 1681.54 182.827 1686.57 187.68L1679.81 198.08Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_410_228">
+<rect width="1706" height="521" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/TalerPOS/Resources/Info.plist b/TalerPOS/Resources/Info.plist
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSCameraUsageDescription</key>
+ <string>Camera access is required to scan QR codes for merchant backend configuration.</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>com.taler-systems.talersale-2.pos-setup</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>taler-pos</string>
+ </array>
+ </dict>
+ </array>
+</dict>
+</plist>
diff --git a/TalerPOS/Resources/Localizable.xcstrings b/TalerPOS/Resources/Localizable.xcstrings
@@ -0,0 +1,11497 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "" : {
+
+ },
+ "-" : {
+
+ },
+ "%@\n%@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@\n%2$@"
+ }
+ }
+ }
+ },
+ "%@: %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@: %2$@"
+ }
+ }
+ }
+ },
+ "%lld" : {
+
+ },
+ "0.00" : {
+
+ },
+ "amount_entry_backspace" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Löschen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backspace"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retroceso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Askelpalautin"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retour arrière"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מחיקה לאחור"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancella"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backspace"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backsteg"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri sil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backspace"
+ }
+ }
+ }
+ },
+ "amount_entry_charge" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Berechnen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Charge"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cobrar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veloita"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Encaisser"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חיוב"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Addebita"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Списать"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Debitera"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ücret al"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Стягнути"
+ }
+ }
+ }
+ },
+ "amount_entry_clear" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zurücksetzen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clear"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tyhjennä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "נקה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancella"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Очистить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rensa"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Temizle"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Очистити"
+ }
+ }
+ }
+ },
+ "amount_entry_create_order_charge" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestellung erstellen (Betrag berechnen)"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Create order (charge amount)"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crear pedido (cobrar importe)"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Luo tilaus (veloita summa)"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créer une commande (encaisser le montant)"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "יצירת הזמנה (חיוב סכום)"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea ordine (addebita importo)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Создать заказ (списать сумму)"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skapa beställning (debitera belopp)"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sipariş oluştur (tutarı ücretlendir)"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Створити замовлення (стягнути суму)"
+ }
+ }
+ }
+ },
+ "amount_entry_error_wrong_currency" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dies ist keine unterstützte Währung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unsupported currency"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Moneda no admitida"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuuttaa ei tueta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devise non prise en charge"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מטבע לא נתמך"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuta non supportata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неподдерживаемая валюта"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valutan stöds inte"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Desteklenmeyen para birimi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Непідтримувана валюта"
+ }
+ }
+ }
+ },
+ "amount_entry_error_zero" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte geben Sie einen Betrag ein"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enter an amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce un importe"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Syötä summa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez un montant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "יש להזין סכום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci un importo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите сумму"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange ett belopp"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bir tutar girin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введіть суму"
+ }
+ }
+ }
+ },
+ "amount_entry_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betrag"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importe"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Summa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Montant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סכום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сумма"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Belopp"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tutar"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сума"
+ }
+ }
+ }
+ },
+ "amount_entry_product_description" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Normale Bestellung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Regular order"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pedido normal"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tavallinen tilaus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commande normale"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הזמנה רגילה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ordine normale"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обычный заказ"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vanlig beställning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Normal sipariş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Звичайне замовлення"
+ }
+ }
+ }
+ },
+ "app_name" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ }
+ }
+ },
+ "app_name_short" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler POS"
+ }
+ }
+ }
+ },
+ "backend.example.com" : {
+
+ },
+ "common_error" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка"
+ }
+ }
+ }
+ },
+ "common_ok" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aceptar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אישור"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ОК"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OK"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tamam"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ОК"
+ }
+ }
+ }
+ },
+ "config_auth_error" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Benutzername oder Passwort ungültig"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Invalid username or password"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Usuario o contraseña inválido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Virheellinen käyttäjätunnus tai salasana"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identifiant ou Mot de passe invalide"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: שם משתמש או סיסמה לא תקינים"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: nome utente o password non validi"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Неверное имя пользователя или пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Ogiltigt användarnamn eller lösenord"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata! Geçersiz kullanıcı adı veya şifre"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: недійсне ім\\'я користувача або пароль"
+ }
+ }
+ }
+ },
+ "config_camera_permission" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera permission is required to scan QR codes"
+ }
+ }
+ }
+ },
+ "config_changed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neuer Händler für %@ aktiviert"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Changed to new merchant using %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambiado al nuevo comercio usando %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vaihdettu uudeksi kauppiaaksi käyttämällä %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Changé en nouveau marchand en utilisant %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הוחלף לסוחר חדש עם %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passato al nuovo commerciante con %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изменено на нового продавца используя %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Byt till ny säljare med %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ kullanılarak yeni satıcı olarak değiştirildi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Змінено на нового продавця, використовуючи %@"
+ }
+ }
+ }
+ },
+ "config_docs" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Informationen zum Konfigurationsformat finden Sie in der Dokumentation."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please refer to the documentation for the configuration format."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Por favor consulta la documentación para la configuración del formato."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Katso määritysmuodon dokumentaatiosta."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veuillez vous référer à la documentation pour le format de configuration."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "עיינו בתיעוד עבור מבנה התצורה."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Consulta la documentazione per il formato della configurazione."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пожалуйста перейдите к документации по формату конфигурации"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se dokumentationen för konfigurationsformat."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lütfen kurulum formatı için belgelere bakın."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, зверніться до документації для формату конфігурації."
+ }
+ }
+ }
+ },
+ "config_error_category" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Keine gültige Produktkategorie gefunden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No valid product category found"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No se encontró ninguna categoría de producto válida"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: kelvollista tuoteluokkaa ei löytynyt"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : aucune catégorie de produit valide n\\'a été trouvée"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא נמצאה קטגוריית מוצרים תקינה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: nessuna categoria prodotto valida trovata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: не удается найти категорию товара"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Ingen giltig produktkategori hittades"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Geçerli ürün kategorisi bulunamadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: не знайдено дійсної категорії продукту"
+ }
+ }
+ }
+ },
+ "config_error_currency" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Produkt %1$@ hat die Währung %2$@, aber %3$@ wird erwartet"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Product %1$@ has currency %2$@, but %3$@ expected"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Producto %1$@ tiene una divisa %2$@, pero %3$@ esperada"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Tuotteen %1$@ valuutta on %2$@, mutta %3$@ odotetaan"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : Le produit %1$@ a la devise %2$@, mais devrait avoir %3$@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: למוצר %1$@ יש מטבע %2$@, אך נדרש %3$@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: il prodotto %1$@ ha valuta %2$@, ma era attesa %3$@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Продукт %1$@ имеет валюту %2$@, но ожидалось %3$@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Produkten %1$@ har valutan %2$@, men %3$@ förväntas"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "\"Hata: %1$@ ürününün para birimi %2$@ ama %3$@ bekleniyor\""
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: продукт %1$@ має валюту %2$@, але очікувалось %3$@"
+ }
+ }
+ }
+ },
+ "config_error_empty_token" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte geben Sie ein Zugangstoken ein"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please enter an access token"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduzca un token de acceso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Syötä käyttöoikeustunnus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veuillez saisir un jeton d'accès"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אנא הזינו אסימון גישה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci un token di accesso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите токен доступа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange en åtkomsttoken"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lütfen bir erişim jetonu girin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, введіть токен доступу"
+ }
+ }
+ }
+ },
+ "config_error_empty_url" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte geben Sie eine Händler-URL ein"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please enter a merchant URL"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduzca la URL del comerciante"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Syötä kauppiaan URL-osoite"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veuillez saisir une URL de commerçant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אנא הזינו כתובת URL של סוחר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci l'URL del commerciante"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите URL торговой точки"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange handlarens URL"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lütfen bir satıcı URL'si girin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, введіть URL торговця"
+ }
+ }
+ }
+ },
+ "config_error_malformed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Die JSON-Konfiguration ist fehlerhaft"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: The configuration JSON is malformed"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: La configuración JSON está malformada"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Määrityksen JSON on virheellinen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : la configuration JSON est incorrecte"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: קובץ ה-JSON של התצורה פגום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: il JSON di configurazione non è valido"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Битый JSON конфигурации"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: JSON-konfigurationen är felaktig"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: JSON konfigürasyonu hatalı oluşturulmuş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: конфігураційний JSON некоректний"
+ }
+ }
+ }
+ },
+ "config_error_network" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Es konnte keine Verbindung zum Konfigurationsserver hergestellt werden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Could not connect to configuration server"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No se pudo conectar al servidor de configuración"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Ei voitu muodostaa yhteyttä määrityspalvelimeen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : impossible de se connecter au serveur de configuration"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא ניתן להתחבר לשרת התצורה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: impossibile connettersi al server di configurazione"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: не удается подключиться к серверу конфигурации"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Det gick inte att ansluta till konfigurationsservern"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Konfigürasyon sunucusuna bağlanılamadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: не вдалося підключитися до сервера конфігурації"
+ }
+ }
+ }
+ },
+ "config_error_product_category_id" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Produkt %1$@ verweist auf unbekannte Kategorie %2$d"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Product %1$@ references unknown category ID %2$d"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Producto %1$@ referencia categoría desconocida ID %2$d"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Tuote %1$@ viittaa tuntemattomaan luokkatunnukseen %2$d"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : le produit %1$@ fait référence à un identifiant de catégorie inconnu %2$d"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: המוצר %1$@ מפנה למזהה קטגוריה לא ידוע %2$d"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: il prodotto %1$@ fa riferimento alla categoria sconosciuta ID %2$d"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Продукт %1$@ ссылается на неизвестную категорию с ID %2$d"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Produkt %1$@ refererar till okänd kategori ID %2$d"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Ürün %1$@, bilinmeyen kategori kimliğine referans veriyor %2$d"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: продукт %1$@ посилається на невідомий ідентифікатор категорії %2$d"
+ }
+ }
+ }
+ },
+ "config_error_product_zero" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Keine gültigen Produkte gefunden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No valid products found"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No se encontraron productos válidos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: kelvollisia tuotteita ei löytynyt"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : aucun produit valide trouvé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא נמצאו מוצרים תקינים"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: Nessun prodotto valido trovato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: не удается найти товар"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Inga giltiga produkter hittades"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Geçerli ürün bulunamadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: не знайдено дійсних продуктів"
+ }
+ }
+ }
+ },
+ "config_error_unknown" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Ungültige Konfiguration"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Invalid configuration"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Configuración inválida"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Virheellinen määritys"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : configuration non valide"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: תצורה לא תקינה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: Configurazione non valida"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Неправильная конфигурация"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Ogiltig konfiguration"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Geçersiz kurulum"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: Недійсна конфігурація"
+ }
+ }
+ }
+ },
+ "config_fetching" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konfiguration wird abgerufen…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fetching configuration…"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obteniendo configuración…"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Haetaan määritystä…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Récupération de la configuration…"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "טוען תצורה…"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recupero configurazione…"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Получение конфигурации…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hämtar konfiguration …"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kurulum getiriliyor…"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отримання конфігурації…"
+ }
+ }
+ }
+ },
+ "config_fetching_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konfiguration wird abgerufen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fetching configuration"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obteniendo configuración"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Haetaan määritystä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Récupération de la configuration"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "טוען תצורה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recupero configurazione"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Получение конфигурации"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hämtar konfiguration"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kurulum getiriliyor"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отримання конфігурації"
+ }
+ }
+ }
+ },
+ "config_forget_password" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vergessen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forget"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Olvidar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unohda"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oublier"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שכח"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dimentica"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не сохранять пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Glöm"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unut"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не зберігати пароль"
+ }
+ }
+ }
+ },
+ "config_fragment_camera_needed_text" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Für den QR-Scan müssen Sie die Berechtigung für die Kamera erteilen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Camera permission is required for QR scanning"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se requiere permiso de cámara para escanear QR"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-skannaus vaatii kameran käyttöoikeuden"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’autorisation de la caméra est requise pour scanner le QR"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "נדרשת הרשאת מצלמה לסריקת QR"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "È richiesta l’autorizzazione della fotocamera per la scansione QR"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Для сканирования QR требуется разрешение на камеру"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kamerabehörighet krävs för QR-skanning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR tarama için kamera izni gereklidir"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Для сканування QR потрібен дозвіл на камеру"
+ }
+ }
+ }
+ },
+ "config_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Händlereinstellungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Merchant settings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes comerciante"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kauppiaan asetukset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètres marchand"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הגדרות סוחר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impostazioni commerciante"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройки Продавца"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Säljar-inställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satıcı ayarları"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Налаштування продавця"
+ }
+ }
+ }
+ },
+ "config_manual_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manuelle Konfiguration"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manual config"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuración manual"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manuaalinen määritys"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuration manuelle"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "תצורה ידנית"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configurazione manuale"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ручная настройка"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manuell konfiguration"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manuel yapılandırma"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ручна конфігурація"
+ }
+ }
+ }
+ },
+ "config_merchant_url" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Händlerportal-URL"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Merchant portal URL"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL de vendedor"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kauppiasportaalin URL"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL du commerçant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "כתובת פורטל הסוחר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL portale commerciante"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL портала продавца"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Handlarportalens URL"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satıcı adresi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL продавця"
+ }
+ }
+ }
+ },
+ "config_mode_manual" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manual"
+ }
+ }
+ }
+ },
+ "config_mode_qr" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR Code"
+ }
+ }
+ }
+ },
+ "config_ok" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbinden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connect"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obtener configuración"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nouda määritykset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Récupérer la configuration"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התחבר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connetti"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Получить конфигурацию"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hämta konfiguration"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konfigürasyonu getir"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отримати конфігурацію"
+ }
+ }
+ }
+ },
+ "config_old_deprecation" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Diese Konfigurationsmethode ist unterdrückt, bitte benutzen Sie die neue Schnittstellenkonfiguration."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This configuration method is deprecated, please use the new merchant API configuration."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Este método de configuración está obsoleto, por favor usa la nueva configuración API para vendedor."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tämä määritystapa on vanhentunut, käytä uutta kauppias-API-määritystä."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cette méthode de configuration est obsolète, veuillez utiliser la nouvelle configuration de l\\'API marchande."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שיטת תצורה זו מיושנת, השתמשו בתצורת API הסוחר החדשה."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Questo metodo di configurazione è deprecato, usa la nuova configurazione API commerciante."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот способ настройки устарел, используйте новую настройку через API продавца."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Den här konfigurationsmetoden är föråldrad, använd den nya konfigurationen via handlar-API:t."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bu yapılandırma yöntemi kullanımdan kaldırıldı. Lütfen yeni satıcı API yapılandırmasını kullanın."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Цей метод конфігурації застарілий, будь ласка, використовуйте нову конфігурацію API продавця."
+ }
+ }
+ }
+ },
+ "config_old_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON-Datei (alt)"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON file (old)"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Archivo JSON (antiguo)"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON-tiedosto (vanha)"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fichier JSON (ancien)"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קובץ JSON (ישן)"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "File JSON (vecchio)"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Файл JSON (старый)"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON-fil (gammal)"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON dosyası (eski)"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "JSON файл (старий)"
+ }
+ }
+ }
+ },
+ "config_password" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contraseña"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salasana"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mot de passe"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סיסמה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lösenord"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Şifre"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пароль"
+ }
+ }
+ }
+ },
+ "config_qr_error" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Not a valid POS setup QR code"
+ }
+ }
+ }
+ },
+ "config_qr_hint" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scan a Merchant Backoffice QR code to connect"
+ }
+ }
+ }
+ },
+ "config_qr_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-Code-Konfiguration"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR config"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuración QR"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-määritys"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuration QR"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "תצורת QR"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configurazione QR"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-конфигурация"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-konfiguration"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR yapılandırması"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "QR-конфігурація"
+ }
+ }
+ }
+ },
+ "config_save_password" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort merken"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remember password"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recordar contraseña"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Muista salasana"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se rappeler du mot de passe"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שמירת הסיסמה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricorda password"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Запомнить пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kom ihåg lösenord"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Şifreyi hatırla"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Запам\\'ятати пароль"
+ }
+ }
+ }
+ },
+ "config_setup_password" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Händler (neu)"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vendedor (nuevo)"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salasana"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commerçant (nouveau)"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סיסמה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lösenord"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satıcı (yeni)"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Продавець (новий)"
+ }
+ }
+ }
+ },
+ "config_token" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contraseña"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salasana"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mot de passe"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סיסמה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пароль"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lösenord"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Şifre"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пароль"
+ }
+ }
+ }
+ },
+ "config_url" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konfigurations-URL"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuration URL"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configuración URL"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Määrityksen URL -osoite"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL de configuration"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "כתובת תצורה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL configurazione"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL конфигурации"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konfigurations-URL"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yapılandırma adresi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL конфігурації"
+ }
+ }
+ }
+ },
+ "config_username" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzername"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Username"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre de usuario"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Käyttäjänimi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identifiant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שם משתמש"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nome utente"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Имя пользователя"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Användarnamn"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kullanıcı adı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ім\\'я користувача"
+ }
+ }
+ }
+ },
+ "custom_duration" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Frei wählbarer Zeitraum"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Custom duration"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Duración personalizada"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mukautettu kesto"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durée personnalisée"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "משך מותאם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durata personalizzata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пользовательская длительность"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anpassad varaktighet"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Özel süre"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Власна тривалість"
+ }
+ }
+ }
+ },
+ "duration" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gültigkeitszeitraum"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Duration"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Duración"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kesto"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durée"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "משך"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Длительность"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Varaktighet"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Süre"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Тривалість"
+ }
+ }
+ }
+ },
+ "Email" : {
+
+ },
+ "error_cancelled" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bezahlvorgang abgebrochen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment cancelled"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago cancelado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu peruutettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement annulé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התשלום בוטל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento annullato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платеж отменен"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning Avbruten"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme iptal edildi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплату скасовано"
+ }
+ }
+ }
+ },
+ "error_delete_order" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Bestellung konnte nicht gelöscht werden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Could not delete order"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: no se pudo eliminar el pedido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: tilausta ei voitu poistaa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : impossible de supprimer la commande"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא ניתן למחוק את ההזמנה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: impossibile cancellare l’ordine"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: не удалось удалить заказ"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: kunde inte ta bort bestallning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: sipariş silinemedi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: не вдалося видалити замовлення"
+ }
+ }
+ }
+ },
+ "error_history" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler beim Abrufen des Bestellverlaufs"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error fetching the order history"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error obteniendo el historial de pedidos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe noudettaessa tilaushistoriaa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur d\\'accès à l\\'historique des commandes"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה בטעינת היסטוריית ההזמנות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore durante il recupero dello storico pagamenti"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка получения истории платежей"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Det gick inte att hämta orderhistoriken"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sipariş geçmişi getirilirken hata oluştu"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка отримання історії замовлень"
+ }
+ }
+ }
+ },
+ "error_inventory_unavailable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Nicht genug Inventar verfügbar"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Not enough inventory available"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: no hay inventario suficiente disponible"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: varastoa ei ole riittävästi saatavilla"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : stock disponible insuffisant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: אין מספיק מלאי זמין"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: inventario disponibile insufficiente"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: недостаточно доступного инвентаря"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: inte tillrackligt lager tillgangligt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: yeterli envanter mevcut değil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: недостатньо доступного інвентарю"
+ }
+ }
+ }
+ },
+ "error_order_creation" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Bestellung konnte nicht erstellt werden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Could not create order"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: no se pudo crear el pedido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: tilausta ei voitu luoda"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : impossible de créer la commande"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא ניתן ליצור הזמנה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: impossibile creare l’ordine"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: не удалось создать заказ"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: kunde inte skapa bestallning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: sipariş oluşturulamadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: не вдалося створити замовлення"
+ }
+ }
+ }
+ },
+ "error_payment" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler: Keine Zahlung erhalten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: No payment received"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error: Pago no recibido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virhe: Maksua ei vastaanotettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur : Aucun paiement reçu"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה: לא התקבל תשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore: Nessun pagamento ricevuto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка: Никакого платежа не получено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel: Ingen betalning mottagen"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hata: Ödeme alınmadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка: Оплату не отримано"
+ }
+ }
+ }
+ },
+ "error_timeout" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keine Zahlung innerhalb des vorgegebenen Zeitraums erhalten, bitte versuchen Sie es erneut!"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No payment made within payment period, please try again!"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se ha realizado ningún pago dentro del periodo de pago, ¡inténtalo de nuevo!"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksua ei ole suoritettu maksuajan kuluessa, yritä uudelleen!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun paiement n\\'a été effectué pendant la période de paiement, veuillez réessayer !"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לא בוצע תשלום בזמן שהוקצב, נסו שוב!"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nessun pagamento effettuato entro il periodo previsto, riprova."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платеж не был сделан за срок оплаты, пожалуйста попробуйте снова!"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen betalning har gjorts i tid, försök igen!"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme süresi içinde ödeme yapılmadı, lütfen tekrar deneyin!"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплату не зроблено протягом періоду оплати, будь ласка, спробуйте ще раз!"
+ }
+ }
+ }
+ },
+ "expires_on" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gültig bis…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Expires on…"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Caduca el…"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vanhenee…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Expire le …"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "פג בתאריך…"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scade il…"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Истекает…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Går ut…"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitiş tarihi…"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Закінчується…"
+ }
+ }
+ }
+ },
+ "force_delete_dialog_confirm" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Löschen erzwingen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Force delete"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forzar eliminación"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pakota poisto"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forcer la suppression"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מחיקה כפויה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elimina forzatamente"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Удалить принудительно"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tvinga borttagning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zorla sil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Примусово видалити"
+ }
+ }
+ }
+ },
+ "force_delete_dialog_message" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Diese Bestellung wurde von einer Wallet beansprucht. Durch das erzwungene Löschen werden alle Bestandssperren aufgehoben und die Wallet kann die Zahlung nicht mehr abschließen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This order has been claimed by a wallet. Force deleting it will release any inventory locks and prevent the wallet from completing the payment."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Este pedido ha sido reclamado por una cartera. Forzar su eliminación liberará cualquier bloqueo de inventario e impedirá que la cartera complete el pago."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lompakko on lunastanut tämän tilauksen. Pakotettu poistaminen vapauttaa kaikki varastovaraukset ja estää lompakkoa suorittamasta maksua."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cette commande a été réclamée par un portefeuille. Forcer sa suppression libérera tous les verrouillages de stock et empêchera le portefeuille de finaliser le paiement."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ההזמנה נתבעה על ידי ארנק. מחיקה כפויה שלה תשחרר את כל נעילות המלאי ותמנע מהארנק להשלים את התשלום."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Questo ordine è stato reclamato da un portafoglio. L’eliminazione forzata rilascerà tutti i blocchi dell’inventario e impedirà al portafoglio di completare il pagamento."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот заказ был заявлен кошельком. Принудительное удаление снимет все блокировки запасов и не позволит кошельку завершить оплату."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Den här beställningen har reserverats av en plånbok. En tvingad borttagning frigör alla lagerlås och hindrar plånboken från att slutföra betalningen."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bu sipariş bir cüzdan tarafından talep edildi. Zorla silinmesi tüm envanter kilitlerini kaldıracak ve cüzdanın ödemeyi tamamlamasını engelleyecektir."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Це замовлення було заявлено гаманцем. Примусове видалення зніме всі блокування запасів і не дозволить гаманцю завершити оплату."
+ }
+ }
+ }
+ },
+ "force_delete_dialog_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Löschen der Bestellung erzwingen?"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Force delete order?"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Forzar la eliminación del pedido?"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pakotetaanko tilauksen poistaminen?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forcer la suppression de la commande ?"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לכפות את מחיקת ההזמנה?"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminare forzatamente l’ordine?"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Принудительно удалить заказ?"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tvinga borttagning av beställningen?"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sipariş zorla silinsin mi?"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Примусово видалити замовлення?"
+ }
+ }
+ }
+ },
+ "forever" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unbefristet"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Forever"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para siempre"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ikuisesti"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Toujours"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לתמיד"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Per sempre"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Навсегда"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "För alltid"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Süresiz"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Назавжди"
+ }
+ }
+ }
+ },
+ "history_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bisher getätigte Zahlungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment history"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historial de pagos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu historia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique des paiements"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "היסטוריית תשלומים"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Storico pagamenti"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "История платежей"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalningshistorik"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme geçmişi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Історія платежів"
+ }
+ }
+ }
+ },
+ "history_ref_no" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beleg #%@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Receipt #%@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recibo #%@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kuitti #%@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Justificatif numéro %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קבלה #%@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricevuta #%@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Чек №%@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kvitto #%@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Makbuz #%@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Квитанція #%@"
+ }
+ }
+ }
+ },
+ "history_refresh" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verlauf aktualisieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refresh history"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actualizar historial"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Päivitä historia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actualiser l’historique"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "רענון היסטוריה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiorna storico"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обновить историю"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uppdatera historik"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geçmişi yenile"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оновити історію"
+ }
+ }
+ }
+ },
+ "history_refund" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "החזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Возврат"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повернення"
+ }
+ }
+ }
+ },
+ "history_show_payment" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlung anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Show payment"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostrar pago"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Näytä maksu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher le paiement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הצגת התשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostra pagamento"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показать платёж"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa betalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödemeyi göster"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показати платіж"
+ }
+ }
+ }
+ },
+ "history_show_refund" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattung anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Show refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostrar reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Näytä palautus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher le remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הצגת ההחזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostra rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показать возврат"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödemeyi göster"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показати повернення"
+ }
+ }
+ }
+ },
+ "history_status_paid" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bezahlt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paid"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שולם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплачено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betald"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödendi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплачено"
+ }
+ }
+ }
+ },
+ "history_status_payment_claimed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlung beansprucht"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment claimed"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago reclamado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu kuitattu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement réclamé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התשלום אושר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento reclamato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платёж подтверждён"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning bekräftad"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme onaylandı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платіж підтверджено"
+ }
+ }
+ }
+ },
+ "history_status_payment_pending" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlung ausstehend"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment pending"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago pendiente"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu odottaa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement en attente"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התשלום ממתין"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento in attesa"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платёж ожидается"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning väntar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme beklemede"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платіж очікується"
+ }
+ }
+ }
+ },
+ "history_status_refund_pending" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattung ausstehend"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refund pending"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reembolso pendiente"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautus odottaa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remboursement en attente"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ההחזר ממתין"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimborso in attesa"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Возврат ожидается"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetalning väntar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme beklemede"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повернення очікується"
+ }
+ }
+ }
+ },
+ "history_status_refunded" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattet"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refunded"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reembolsado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remboursé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הוחזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimborsato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Возвращено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetald"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödendi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повернено"
+ }
+ }
+ }
+ },
+ "history_status_unpaid" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Noch nicht bezahlt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unpaid"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impagado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksamaton"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non payé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לא שולם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non pagato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неоплачено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obetald"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödenmemiş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неоплачено"
+ }
+ }
+ }
+ },
+ "history_unpaid" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Noch nicht bezahlt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unpaid"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impagado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksamaton"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non payé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לא שולם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non pagato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неоплачено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obetald"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödenmemiş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неоплачено"
+ }
+ }
+ }
+ },
+ "host_apdu_service_desc" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NFC-Zahlungen mit Taler Merchant"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler Merchant NFC payments"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagos NFC en Taler Merchant"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler Merchant NFC -maksut"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiements NFC Taler Merchant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "תשלומי NFC של Taler Merchant"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamenti NFC Taler Merchant"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NFC-платежи Taler Merchant"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler Merchant NFC-betalningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Taler Merchant NFC ödemeleri"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NFC платежі Taler Merchant"
+ }
+ }
+ }
+ },
+ "https://" : {
+
+ },
+ "Instance name" : {
+
+ },
+ "menu_amount_entry" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nach Betrag bestellen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Order by amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pedido por importe"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tilaus summan mukaan"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commande par montant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הזמנה לפי סכום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ordine per importo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Заказ по сумме"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beställ efter belopp"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tutara göre sipariş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Замовлення за сумою"
+ }
+ }
+ }
+ },
+ "menu_history" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verlauf"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "History"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Histórico"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historique"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "היסטוריה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Storico"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "История"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Historia"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geçmiş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Історія"
+ }
+ }
+ }
+ },
+ "menu_order" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Normale Bestellung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Order by inventory"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pedidos"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tilaukset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commandes"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הזמנה לפי מלאי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ordini"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Заказы"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siparişler"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Замовлення"
+ }
+ }
+ }
+ },
+ "menu_reload" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventar neu laden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reload inventory"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recargar inventario"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lataa varasto uudelleen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recharger l’inventaire"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "טעינת מלאי מחדש"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricarica inventario"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Перезагрузить инвентарь"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ladda om lager"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envanteri yeniden yükle"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Перезавантажити інвентар"
+ }
+ }
+ }
+ },
+ "menu_settings" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Einstellungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Settings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Asetukset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètres"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הגדרות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impostazioni"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройки"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ayarlar"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Налаштування"
+ }
+ }
+ }
+ },
+ "mfa_cancel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abbrechen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Peruuta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ביטול"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annulla"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отмена"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avbryt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "İptal"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скасувати"
+ }
+ }
+ }
+ },
+ "mfa_challenge_code_hint" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestätigungscode"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verification code"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Código de verificación"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vahvistuskoodi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code de vérification"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קוד אימות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codice di verifica"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Код подтверждения"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifieringskod"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrulama kodu"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Код підтвердження"
+ }
+ }
+ }
+ },
+ "mfa_challenge_code_incomplete" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie alle %lld Ziffern ein."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enter all %lld digits."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce los %lld dígitos."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Syötä kaikki %lld numeroa."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez les %lld chiffres."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "יש להזין את כל %lld הספרות."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci tutte le %lld cifre."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите все %lld цифр."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange alla %lld siffror."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld hanenin tümünü girin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введіть усі %lld цифр."
+ }
+ }
+ }
+ },
+ "mfa_challenge_failed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Verifizierung ist fehlgeschlagen. Bitte versuchen Sie diesen Vorgang nochmals."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verification failed. Please try again later."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La verificación ha fallado. Inténtalo de nuevo más tarde."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vahvistus epäonnistui. Yritä myöhemmin uudelleen."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La vérification a échoué. Veuillez réessayer plus tard."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "האימות נכשל. נסו שוב מאוחר יותר."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica non riuscita. Riprova più tardi."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Проверка не удалась. Повторите попытку позже."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifieringen misslyckades. Försök igen senare."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrulama başarısız oldu. Lütfen daha sonra tekrar deneyin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Підтвердження не вдалося. Спробуйте пізніше."
+ }
+ }
+ }
+ },
+ "mfa_challenge_invalid" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Falscher Bestätigungscode. Bitte versuchen Sie diesen Vorgang nochmals."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Incorrect code. Please try again."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Código incorrecto. Inténtalo de nuevo."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virheellinen koodi. Yritä uudelleen."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code incorrect. Veuillez réessayer."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קוד שגוי. נסו שוב."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codice errato. Riprova."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неверный код. Повторите попытку."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel kod. Försök igen."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kod yanlış. Lütfen tekrar deneyin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неправильний код. Спробуйте ще раз."
+ }
+ }
+ }
+ },
+ "mfa_challenge_message" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein Bestätigungscode wurde über %@ (%@) gesendet. Geben Sie diesen Code ein, um fortzufahren."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "A confirmation code was sent via %@ (%@). Enter the code to continue."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se envió un código de confirmación mediante %@ (%@). Introduce el código para continuar."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vahvistuskoodi lähetettiin kanavalla %@ (%@). Jatka syöttämällä koodi."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un code de confirmation a été envoyé via %@ (%@). Saisissez le code pour continuer."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קוד אישור נשלח דרך %@ (%@). הזינו את הקוד כדי להמשיך."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un codice di conferma è stato inviato tramite %@ (%@). Inserisci il codice per continuare."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Код подтверждения отправлен через %@ (%@). Введите код, чтобы продолжить."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En bekräftelsekod skickades via %@ (%@). Ange koden för att fortsätta."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ (%@) üzerinden bir onay kodu gönderildi. Devam etmek için kodu girin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Код підтвердження надіслано через %@ (%@). Введіть код, щоб продовжити."
+ }
+ }
+ }
+ },
+ "mfa_challenge_retry" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zu viele fehlgeschlagene Versuche. Ein neuer Code wurde Ihnen zugesendet."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Too many attempts. A new code has been sent."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demasiados intentos. Se ha enviado un código nuevo."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Liian monta yritystä. Uusi koodi on lähetetty."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Trop de tentatives. Un nouveau code a été envoyé."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "יותר מדי ניסיונות. קוד חדש נשלח."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Troppi tentativi. È stato inviato un nuovo codice."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Слишком много попыток. Отправлен новый код."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "För många försök. En ny kod har skickats."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Çok fazla deneme yapıldı. Yeni bir kod gönderildi."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Забагато спроб. Надіслано новий код."
+ }
+ }
+ }
+ },
+ "mfa_challenge_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zwei-Faktor-Anmeldeverfahren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Two-factor authentication"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autenticación de dos factores"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kaksivaiheinen tunnistautuminen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authentification à deux facteurs"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אימות דו-שלבי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autenticazione a due fattori"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Двухфакторная аутентификация"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tvåfaktorsautentisering"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "İki faktörlü kimlik doğrulama"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Двофакторна автентифікація"
+ }
+ }
+ }
+ },
+ "mfa_choose_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie die Art des Bestätigungscodes"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose verification method"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige el método de verificación"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valitse vahvistustapa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir la méthode de vérification"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בחירת שיטת אימות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli metodo di verifica"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите способ подтверждения"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Välj verifieringsmetod"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrulama yöntemini seçin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Виберіть спосіб підтвердження"
+ }
+ }
+ }
+ },
+ "mfa_enter_code" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestätigungscode eingeben"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enter verification code"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce el código de verificación"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Syötä vahvistuskoodi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez le code de vérification"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הזינו קוד אימות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il codice di verifica"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите код подтверждения"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange verifieringskod"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrulama kodunu girin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введіть код підтвердження"
+ }
+ }
+ }
+ },
+ "mfa_request_failed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anforderung fehlgeschlagen: %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Failed to request challenge: %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error al solicitar el desafío: %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Haasteen pyytäminen epäonnistui: %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Échec de la demande de vérification : %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בקשת האתגר נכשלה: %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile richiedere la verifica: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось запросить проверку: %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kunde inte begära verifiering: %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrulama isteği başarısız: %@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не вдалося запитати перевірку: %@"
+ }
+ }
+ }
+ },
+ "mfa_resend_code" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code erneut senden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Resend Code"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reenviar código"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lähetä koodi uudelleen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renvoyer le code"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שלח קוד מחדש"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reinvia codice"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отправить код повторно"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skicka koden igen"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kodu tekrar gönder"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Надіслати код повторно"
+ }
+ }
+ }
+ },
+ "mfa_resend_failed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code konnte nicht erneut gesendet werden: %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Failed to resend code: %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error al reenviar el código: %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Koodin uudelleenlähetys epäonnistui: %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Échec du renvoi du code : %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שליחת הקוד מחדש נכשלה: %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile reinviare il codice: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось повторно отправить код: %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kunde inte skicka om koden: %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kod tekrar gönderilemedi: %@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не вдалося надіслати код повторно: %@"
+ }
+ }
+ }
+ },
+ "mfa_verify" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestätigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verify"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vahvista"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifier"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אמת"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подтвердить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifiera"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doğrula"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Підтвердити"
+ }
+ }
+ }
+ },
+ "never_expires" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unbegrenzte Gültigkeit"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Never expires"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nunca caduca"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ei vanhene koskaan"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "N’expire jamais"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לעולם לא פג"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non scade mai"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Никогда не истекает"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Går aldrig ut"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Asla sona ermez"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ніколи не закінчується"
+ }
+ }
+ }
+ },
+ "no_deadline_set" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es wurde keine Verfallsfrist festgelegt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No deadline set"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sin fecha límite"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Määräaikaa ei ole asetettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune date limite"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לא נקבע מועד תפוגה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nessuna scadenza impostata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Срок не задан"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen tidsgräns angiven"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Son tarih ayarlanmadı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Термін не встановлено"
+ }
+ }
+ }
+ },
+ "order_complete" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abrechnung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bill"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valmis"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compléter"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חשבון"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Totale da pagare"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выполнено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Klart"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tamam"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Завершити"
+ }
+ }
+ }
+ },
+ "order_complete_with_amount" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abrechnung %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bill %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cuenta %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lasku %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Note %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חשבון %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Totale da pagare %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Счёт %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nota %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hesap %@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Рахунок %@"
+ }
+ }
+ }
+ },
+ "order_custom" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzerdefinierten Artikel hinzufügen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Add custom product"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Añadir producto personalizado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lisää mukautettu tuote"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter un produit personnalisé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הוסף מוצר מותאם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi prodotto personalizzato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Добавить свой продукт"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lägg till anpassad produkt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Özel ürün ekle"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Додати користувацький продукт"
+ }
+ }
+ }
+ },
+ "order_custom_add_button" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hinzufügen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Add"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Añadir"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lisää"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הוסף"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Добавить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lägg till"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ekle"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Додати"
+ }
+ }
+ }
+ },
+ "order_custom_product" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bezeichnung des Einzelartikels"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Custom product name"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre de producto personalizado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mukautettu tuotteen nimi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de produit personnalisé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שם מוצר מותאם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nome prodotto personalizzato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Название своего продукта"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Namn på anpassad produkt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Özel ürünün ismi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Назва користувацького продукту"
+ }
+ }
+ }
+ },
+ "order_custom_product_default" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tipp"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tip"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Consejo"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conseil"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "טיפ"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mancia"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Чаевые"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dricks"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bahşiş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Чайові"
+ }
+ }
+ }
+ },
+ "order_delete" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestellung löschen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Delete order"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar pedido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Poista tilaus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la commande"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מחק הזמנה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancella ordine"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Удалить заказ"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ta bort bestallning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siparişi sil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Видалити замовлення"
+ }
+ }
+ }
+ },
+ "order_label_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestellung #%@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Order #%@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pedido #%@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tilaus #%@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Commande numéro %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הזמנה #%@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ordine #%@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Заказ №%@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beställning #%@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sipariş #%@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Замовлення #%@"
+ }
+ }
+ }
+ },
+ "order_next" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nächste Bestellung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Next"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siguiente"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seuraava"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Suivant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הבא"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Successivo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Следующая"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nästa"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sonraki"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Наступне"
+ }
+ }
+ }
+ },
+ "order_previous" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vorherige Bestellung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prev"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anterior"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Edellinen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Précédent"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הקודם"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Precedente"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Предыдущая"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Föregående"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Önceki"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Попереднє"
+ }
+ }
+ }
+ },
+ "order_restart" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Leeren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clear"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tyhjennä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Effacer"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "נקה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancella ordine"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Очистить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rensa"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Temizle"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Очистити"
+ }
+ }
+ }
+ },
+ "order_total" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Summe: %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Total: %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Total: %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yhteensä: #%@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Total : %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סה״כ: %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Totale: %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Итого: %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Totalt: %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Toplam: %@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Загальна сума: %@"
+ }
+ }
+ }
+ },
+ "order_undo" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückgängig machen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Undo"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deshacer"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kumoa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בטל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annulla"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отменить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ångra"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri al"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Відмінити"
+ }
+ }
+ }
+ },
+ "payment_back_button" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continue"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jatka"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuer"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "המשך"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continua"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Продолжить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fortsätt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devam et"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Продовжити"
+ }
+ }
+ }
+ },
+ "payment_cancel" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bezahlvorgang abbrechen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancel payment"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar pago"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Peruuta maksu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler le Paiement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ביטול תשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annulla pagamento"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отменить оплату"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avbryt betalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödemeyi iptal et"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скасувати оплату"
+ }
+ }
+ }
+ },
+ "payment_canceled" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Zahlung wurde abgebrochen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment canceled"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago cancelado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu peruutettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement annulé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התשלום בוטל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento annullato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплата отменена"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning avbruten"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme iptal edildi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплату скасовано"
+ }
+ }
+ }
+ },
+ "payment_claimed" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Warten auf Zahlungsbestätigung durch Kunden…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Waiting for customer to confirm payment…"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "A la espera de que el cliente confirme el pago…"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Odotetaan asiakkaan maksun vahvistamista…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En attendant que le client confirme le paiement…"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ממתין לאישור התשלום על ידי הלקוח…"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "In attesa che il cliente confermi il pagamento…"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ожидание пока клиент подтвердит платёж…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Väntar på att kunden ska bekräfta betalningen…"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Müşterinin ödemeyi onaylaması bekleniyor…"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Очікування підтвердження оплати клієнтом…"
+ }
+ }
+ }
+ },
+ "payment_copy_uri" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlungs-URI kopieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copy payment URI"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copiar URI de pago"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kopioi maksun URI"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier l’URI de paiement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "העתקת URI של תשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copia URI di pagamento"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скопировать URI платежа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kopiera betalnings-URI"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme bağlantısını kopyala"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скопіювати URI платежу"
+ }
+ }
+ }
+ },
+ "payment_fetching_link" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlungslink wird abgerufen…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fetching payment link…"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obteniendo enlace de pago…"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отримання посилання для оплати…"
+ }
+ }
+ }
+ },
+ "payment_intro" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte lassen Sie zum Bezahlen den QR-Code scannen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please let customer scan QR Code to pay."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Por favor permite al cliente escanear el código QR para pagar."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anna asiakkaan skannata QR-koodi maksaaksesi."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demandez au client de scanner le QR code pour payer."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בקשו מהלקוח לסרוק קוד QR כדי לשלם."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chiedi al cliente di scansionare il codice QR per pagare."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пожалуйста позвольте клиенту отсканировать QR-код для платежа."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Låt kunden skanna QR-kod för att betala."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lütfen müşterinin ödeme yapmak için QR Kodunu taramasına izin verin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, дозвольте клієнту відсканувати QR-код для оплати."
+ }
+ }
+ }
+ },
+ "payment_intro_nfc" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte zum Bezahlen den QR-Code scannen oder per NFC empfangen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please let customer scan QR Code or use NFC to pay."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Por favor permite al cliente escanear el código QR o usar NFC para pagar."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anna asiakkaan skannata QR-koodi tai käyttää NFC:tä maksamiseen."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veuillez laisser le client scanner le QR Code ou utiliser la NFC pour payer."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בקשו מהלקוח לסרוק קוד QR או להשתמש ב-NFC כדי לשלם."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chiedi al cliente di scansionare il codice QR o usare NFC per pagare."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Для совершения платежа покупателем, разрешите сканирование QR-кода или использование NFC."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Låt kunden skanna QR-kod eller använd NFC för att betala."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lütfen müşterinin QR Kodunu taramasına veya ödeme yapmak için NFC kullanmasına izin verin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, дозвольте клієнту відсканувати QR-код або використати NFC для оплати."
+ }
+ }
+ }
+ },
+ "payment_order_id" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beleg #%@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Receipt #%@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recibo #%@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kuitti #%@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Justificatif numéro %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "קבלה #%@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricevuta #%@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Чек №%@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kvitto #%@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Makbuz #%@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Квитанція #%@"
+ }
+ }
+ }
+ },
+ "payment_process_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kundenzahlung erforderlich"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment required"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago requerido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksua vaaditaan"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement requis"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "נדרש תשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento richiesto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Требуется оплата"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning krävs"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme gerekli"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Необхідна оплата"
+ }
+ }
+ }
+ },
+ "payment_received" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlung erhalten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Payment received"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pago recibido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maksu vastaanotettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paiement reçu"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התשלום התקבל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pagamento ricevuto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Платёж получен"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betalning mottagen"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme alındı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Оплату отримано"
+ }
+ }
+ }
+ },
+ "payment_share_uri" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zahlungs-URI teilen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Share payment URI"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compartir URI de pago"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jaa maksun URI"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Partager l’URI de paiement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שיתוף URI של תשלום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Condividi URI di pagamento"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Поделиться URI платежа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dela betalnings-URI"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ödeme bağlantısını paylaş"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Поділитися URI платежу"
+ }
+ }
+ }
+ },
+ "pick_date" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Datum auswählen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pick date"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir fecha"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valitse päivämäärä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir la date"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בחירת תאריך"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli data"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать дату"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Välj datum"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tarih seç"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Вибрати дату"
+ }
+ }
+ }
+ },
+ "pick_time" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zeitpunkt auswählen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pick time"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir hora"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valitse aika"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir l\\'heure"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בחירת שעה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli ora"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать время"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Välj tid"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saat seç"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Вибрати час"
+ }
+ }
+ }
+ },
+ "product_category_all_objects" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alles"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Everything"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Todo"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kaikki"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tout"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הכול"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tutto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Всё"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Allt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Her şey"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Усе"
+ }
+ }
+ }
+ },
+ "product_category_uncategorized" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ohne Kategorie"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uncategorized"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sin categoría"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Luokittelematon"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sans catégorie"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ללא קטגוריה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Senza categoria"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Без категории"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Okategoriserad"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kategorisiz"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Без категорії"
+ }
+ }
+ }
+ },
+ "product_image" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abbildung des Artikels"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Product image"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Imagen del producto"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tuotekuva"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Image du produit"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "תמונת מוצר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Immagine prodotto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изображение продукта"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Produktbild"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ürün resmi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Зображення продукту"
+ }
+ }
+ }
+ },
+ "product_out_of_stock" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nicht auf Lager"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Out of stock"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agotado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Loppu varastosta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rupture de stock"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אזל מהמלאי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esaurito"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нет в наличии"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Slut i lager"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stokta yok"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Немає в наявності"
+ }
+ }
+ }
+ },
+ "product_unavailable" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nicht verfügbar"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unavailable"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No disponible"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ei saatavilla"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Indisponible"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לא זמין"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non disponibile"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Недоступно"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inte tillganglig"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mevcut değil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Недоступно"
+ }
+ }
+ }
+ },
+ "product_wrong_currency" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Falsche Währung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wrong currency"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Moneda incorrecta"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Väärä valuutta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devise incorrecte"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מטבע שגוי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuta errata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неверная валюта"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel valuta"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Yanlış para birimi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неправильна валюта"
+ }
+ }
+ }
+ },
+ "project_name" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "GNU Taler"
+ }
+ }
+ }
+ },
+ "refund_abort" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abbrechen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abort"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keskeytä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abandonner"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ביטול"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Interrompi"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отменить"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avbryt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durdur"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скасувати"
+ }
+ }
+ }
+ },
+ "refund_amount" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Betrag"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cantidad"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Summa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Montant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סכום"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сумма"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Belopp"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Miktar"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сума"
+ }
+ }
+ }
+ },
+ "refund_complete" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erhalten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Received"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recibido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vastaanotettu"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reçu"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התקבל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricevuto"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Получено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mottagen"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alındı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отримано"
+ }
+ }
+ }
+ },
+ "refund_confirm" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattung bestätigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Approve refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aprobar reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hyväksy hyvitys"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Approuver le remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אישור החזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Approva rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подтвердить возврат"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Godkänn återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödemeyi onayla"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Затвердити повернення"
+ }
+ }
+ }
+ },
+ "refund_error_already_refunded" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bereits erstattet"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Already refunded"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ya reembolsado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautettu jo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Déjà remboursé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "כבר הוחזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Già rimborsato"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Уже возвращено"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Redan återbetalad"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Çoktan geri ödendi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Вже повернуто"
+ }
+ }
+ }
+ },
+ "refund_error_backend" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fehler bei der Verarbeitung der Rückerstattung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error processing refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error procesando el reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hyvityksen käsittelyssä tapahtui virhe"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erreur lors du traitement du remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שגיאה בעיבוד ההחזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Errore durante l’elaborazione del rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при обработке возврата"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fel vid bearbetning av återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme işlenirken hata oluştu"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Помилка обробки повернення"
+ }
+ }
+ }
+ },
+ "refund_error_deadline" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Rückerstattungsfrist ist abgelaufen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refund deadline has passed"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El plazo del reembolso ha vencido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautusaika on umpeutunut"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le délai de remboursement est dépassé"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מועד ההחזר חלף"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La scadenza del rimborso è passata"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Возврат просрочен"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetalningstiden har passerats"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme süresi geçti"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Термін повернення минув"
+ }
+ }
+ }
+ },
+ "refund_error_invalid_amount" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ungültiger Betrag"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Invalid amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cantidad inválida"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Virheellinen summa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Montant invalide"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סכום לא תקין"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Importo non valido"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неправильное количество"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ogiltigt belopp"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geçersiz miktar"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Недійсна сума"
+ }
+ }
+ }
+ },
+ "refund_error_max_amount" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Größer als Bestellmenge von %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Greater than order amount of %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mayor que la cantidad del pedido de %@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Suurempi kuin tilausmäärä %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supérieur au montant de la commande de %@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "גדול מסכום ההזמנה %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Maggiore dell’importo dell’ordine di %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сумма возврата превосходит сумму заказа %@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Större än orderbelopp på %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sipariş miktarından büyük"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Більше, ніж сума замовлення %@"
+ }
+ }
+ }
+ },
+ "refund_error_zero" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Der Betrag muss positiv sein"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Needs to be positive amount"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Debe ser una cantidad positiva"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "On oltava positiivinen määrä"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Doit être un montant positif"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חייב להיות סכום חיובי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deve essere un importo positivo"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сумма должна быть положительным числом"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Måste vara positivt"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pozitif miktar olması gerekiyor"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сума повинна бути додатним числом"
+ }
+ }
+ }
+ },
+ "refund_intro" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zur Rückerstattung lassen Sie bitte den QR-Code einscannen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please let customer scan QR Code to offer refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Por favor permite al cliente escanear código QR para ofrecer un reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anna asiakkaan skannata QR-koodi hyvityksen tarjoamiseksi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demandez au client de scanner le QR code pour effectuer le remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בקשו מהלקוח לסרוק קוד QR כדי לקבל החזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chiedi al cliente di scansionare il codice QR per ricevere il rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пожалуйста дайте клиенту отсканировать QR-код чтобы предложить возврат"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Låt kunden skanna QR-koden för att erbjuda återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme sunmak için lütfen müşterinin QR Kodunu taramasına izin verin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, дозвольте клієнту відсканувати QR-код для пропозиції повернення"
+ }
+ }
+ }
+ },
+ "refund_intro_nfc" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte scannen Sie den QR-Code oder verwenden Sie NFC, um eine Rückerstattung zu erhalten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Please let customer scan QR Code or use NFC to offer refund"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Por favor permite al cliente escanear el código QR o usar NFC para ofrecer un reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anna asiakkaan skannata QR-koodi tai käyttää NFC:tä hyvityksen tarjoamiseksi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demandez au client de scanner le QR code ou d\\'utiliser la NFC pour effectuer le remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בקשו מהלקוח לסרוק קוד QR או להשתמש ב-NFC כדי לקבל החזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chiedi al cliente di scansionare il codice QR o usare NFC per ricevere il rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Пожалуйста дайте клиенту отсканировать QR-код или используйте NFC чтобы предложить возврат"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Låt kunden skanna QR-kod eller använd NFC för att erbjuda återbetalning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme sunmak için lütfen müşterinin QR Kodunu taramasına veya NFC kullanmasına izin verin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Будь ласка, дозвольте клієнту відсканувати QR-код або використати NFC для пропозиції повернення"
+ }
+ }
+ }
+ },
+ "refund_order_ref" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Referenznummer des Kaufs: %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Purchase reference: %1$@\\n\\n%2$@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Referencia de compra : %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ostoviite: %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Référence d\\'achat : %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אסמכתת רכישה: %1$@\\n\\n%2$@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Riferimento acquisto: %1$@\\n\\n%2$@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Номер вашего заказа: %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inköpsreferens: %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satın alma referansı %1$@\n\\n\n\\n%2$@"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Посилання на покупку: %1$@\n\\n\n\\n%2$@"
+ }
+ }
+ }
+ },
+ "refund_reason" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückerstattungsgrund"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refund reason"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Razón del reembolso"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautuksen syy"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Motif du remboursement"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סיבת ההחזר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Causale rimborso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Причина возврата"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetalningsskäl"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme nedeni"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Причина повернення"
+ }
+ }
+ }
+ },
+ "refund_state_missing" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Der Rückerstattungsstatus ist nicht mehr verfügbar"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Refund state is no longer available"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El estado del reembolso ya no está disponible"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Palautuksen tila ei ole enää saatavilla"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’état du remboursement n’est plus disponible"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מצב ההחזר אינו זמין עוד"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stato rimborso non disponibile"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Статус возврата больше недоступен"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Återbetalningsstatus är inte längre tillgänglig"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geri ödeme durumu artık kullanılamıyor"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Стан повернення більше недоступний"
+ }
+ }
+ }
+ },
+ "scan_qr_hint" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scannen Sie den QR-Code, den das Taler Merchant Backoffice erzeugt hat"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scan the QR code from Merchant Backoffice"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escanea el código QR del backoffice del comercio"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skannaa QR-koodi kauppiaan backofficesta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scannez le code QR depuis le backoffice commerçant"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "סרקו את קוד ה-QR ממשרד האחורי של הסוחר"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scansiona il codice QR dal backoffice commerciante"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Отсканируйте QR-код из бэк-офиса продавца"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skanna QR-koden från handlarens backoffice"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satıcı backoffice’inden QR kodunu tarayın"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Скануйте QR-код із бекофісу продавця"
+ }
+ }
+ }
+ },
+ "session_expired_toast" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Sitzung ist abgelaufen – bitte geben Sie Ihre Anmeldedaten erneut ein"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Session expired – please re-enter your credentials"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La sesión ha caducado – introduce de nuevo tus credenciales"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Istunto vanheni – syötä tunnistetiedot uudelleen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La session a expiré – veuillez saisir à nouveau vos informations d\\'identification"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ההפעלה פגה – הזינו שוב את פרטי הגישה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sessione scaduta – inserisci di nuovo le credenziali"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сеанс истек – введите учетные данные снова"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sessionen har gått ut – ange dina inloggningsuppgifter igen"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oturum süresi doldu – lütfen kimlik bilgilerinizi yeniden girin"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сеанс завершився – введіть облікові дані знову"
+ }
+ }
+ }
+ },
+ "settings_about_app" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sovellus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אפליקציה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Приложение"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Додаток"
+ }
+ }
+ }
+ },
+ "settings_about_currency" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Währung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Currency"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Moneda"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuutta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Devise"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מטבע"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuta"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Валюта"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valuta"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para birimi"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Валюта"
+ }
+ }
+ }
+ },
+ "settings_about_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Über"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "About"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Acerca de"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tietoja"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "À propos"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "אודות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Informazioni"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "О приложении"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Om"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hakkında"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Про додаток"
+ }
+ }
+ }
+ },
+ "settings_about_version" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versión"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versio"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "גרסה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versione"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Версия"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sürüm"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Версія"
+ }
+ }
+ }
+ },
+ "settings_app_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App-Einstellungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App settings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes de la app"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sovelluksen asetukset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètres de l’application"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הגדרות אפליקציה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impostazioni app"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройки приложения"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appinställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama ayarları"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Налаштування застосунку"
+ }
+ }
+ }
+ },
+ "settings_initial_order_description" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird beim Start der App oder nach dem Laden der Konfiguration angezeigt."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Shown when the app starts or after configuration is loaded."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se muestra al iniciar la app o después de cargar la configuración."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Näytetään, kun sovellus käynnistyy tai kun määritykset on ladattu."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Affiché au démarrage de l’application ou après le chargement de la configuration."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מוצג כשהאפליקציה מופעלת או אחרי טעינת התצורה."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostrata all’avvio dell’app o dopo il caricamento della configurazione."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показывается при запуске приложения или после загрузки конфигурации."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visas när appen startar eller efter att konfigurationen har laddats."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama başlatıldığında veya yapılandırma yüklendikten sonra gösterilir."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Показується під час запуску застосунку або після завантаження конфігурації."
+ }
+ }
+ }
+ },
+ "settings_initial_order_hint" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standard-Bestellmodus"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Default order mode"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modo de pedido predeterminado"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oletustilaustapa"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mode de commande par défaut"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מצב הזמנה ברירת מחדל"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modalità ordine predefinita"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Режим заказа по умолчанию"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standardläge för beställning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Varsayılan sipariş modu"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Тип замовлення за замовчуванням"
+ }
+ }
+ }
+ },
+ "settings_initial_order_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Startbildschirm für Bestellungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Initial order screen"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pantalla inicial de pedido"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aloitusnäkymä tilauksille"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Écran de commande initial"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מסך הזמנה התחלתי"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Schermata iniziale ordine"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Начальный экран заказа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Startskärm för beställning"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Başlangıç sipariş ekranı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Початковий екран замовлення"
+ }
+ }
+ }
+ },
+ "settings_instance_button" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anmeldeeinstellungen ändern"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modify login settings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modificar ajustes de inicio de sesión"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Muokkaa kirjautumisasetuksia"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifier les paramètres de connexion"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שינוי הגדרות התחברות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifica impostazioni di accesso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изменить настройки входа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ändra inloggningsinställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oturum açma ayarlarını değiştir"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Змінити налаштування входу"
+ }
+ }
+ }
+ },
+ "settings_instance_description" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Händlerportal-URL, Benutzername, Passwort und weitere Einstellungen ändern."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Change merchant portal URL, username, password, and other settings."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambia la URL del portal del comercio, el nombre de usuario, la contraseña y otros ajustes."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Muuta kauppiasportaalin URL-osoitetta, käyttäjätunnusta, salasanaa ja muita asetuksia."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifier l’URL du portail commerçant, le nom d’utilisateur, le mot de passe et les autres paramètres."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שינוי כתובת פורטל הסוחר, שם המשתמש, הסיסמה והגדרות נוספות."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modifica l’URL del portale commerciante, il nome utente, la password e altre impostazioni."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Изменить URL портала продавца, имя пользователя, пароль и другие настройки."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ändra handlarportalens URL, användarnamn, lösenord och andra inställningar."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Satıcı portalı URL’sini, kullanıcı adını, şifreyi ve diğer ayarları değiştirin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Змінити URL порталу продавця, ім’я користувача, пароль та інші налаштування."
+ }
+ }
+ }
+ },
+ "settings_instance_title" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anmeldeeinstellungen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Login settings"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes de inicio de sesión"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kirjautumisasetukset"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètres de connexion"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "הגדרות התחברות"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impostazioni di accesso"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Настройки входа"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inloggningsinställningar"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oturum açma ayarları"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Налаштування входу"
+ }
+ }
+ }
+ },
+ "settings_language_description" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird sofort auf die App-Oberfläche angewendet."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Applied immediately to the app interface."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se aplica inmediatamente a la interfaz de la app."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Käytetään heti sovelluksen käyttöliittymässä."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "S’applique immédiatement à l’interface de l’application."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מוחל מיד על ממשק האפליקציה."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Applicata immediatamente all’interfaccia dell’app."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Применяется к интерфейсу приложения сразу."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tillämpas direkt på appens gränssnitt."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama arayüzüne hemen uygulanır."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Застосовується до інтерфейсу застосунку негайно."
+ }
+ }
+ }
+ },
+ "settings_language_hint" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App-Sprache"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App language"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Idioma de la app"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sovelluksen kieli"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Langue de l’application"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שפת האפליקציה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lingua dell’app"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Язык приложения"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appspråk"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama dili"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Мова застосунку"
+ }
+ }
+ }
+ },
+ "settings_language_label" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sprache"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Language"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Idioma"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kieli"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Langue"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "שפה"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lingua"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Язык"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Språk"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dil"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Мова"
+ }
+ }
+ }
+ },
+ "settings_language_system_default" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Systemvorgabe"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "System default"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Predeterminado del sistema"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Järjestelmän oletus"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paramètre système"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ברירת מחדל של המערכת"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Predefinita di sistema"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Системный"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Systemstandard"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sistem varsayılanı"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Системна"
+ }
+ }
+ }
+ },
+ "settings_logout_button" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abmelden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Log out"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar sesión"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kirjaudu ulos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se déconnecter"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "התנתק"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Disconnetti"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выйти"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Logga ut"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oturumu kapat"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Вийти"
+ }
+ }
+ }
+ },
+ "settings_subtitle" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie die App-Sprache und verwalten Sie die Anmeldeeinstellungen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose the app language and manage login settings."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige el idioma de la app y gestiona los ajustes de inicio de sesión."
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Valitse sovelluksen kieli ja hallitse kirjautumisasetuksia."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez la langue de l’application et gérez les paramètres de connexion."
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "בחרו את שפת האפליקציה ונהלו את הגדרות ההתחברות."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli la lingua dell’app e gestisci le impostazioni di accesso."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выберите язык приложения и управляйте настройками входа."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Välj appspråk och hantera inloggningsinställningar."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama dilini seçin ve oturum açma ayarlarını yönetin."
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Виберіть мову застосунку та керуйте налаштуваннями входу."
+ }
+ }
+ }
+ },
+ "SMS" : {
+
+ },
+ "toast_back_to_exit" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Klicken Sie zum Beenden erneut auf «Zurück»"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Click «back» again to exit"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Haz clic en «atrás» de nuevo para salir"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Napsauta «takaisin» uudelleen poistuaksesi"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cliquez à nouveau sur «retour» pour quitter"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "לחצו שוב על «חזרה» כדי לצאת"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Premi di nuovo «indietro» per uscire"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Нажмите «назад» ещё раз чтобы выйти"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Klicka på «tillbaka» igen för att avsluta"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Çıkmak için tekrar «geri» yi tıklayın"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Натисніть «назад» ще раз, щоб вийти"
+ }
+ }
+ }
+ },
+ "toast_reloading" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventar wird neu geladen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reloading inventory"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recargando inventario"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ladataan varastoa uudelleen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rechargement de l\\'inventaire"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "טוען מלאי מחדש"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ricaricamento inventario"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Перезагрузка инвентаря"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Laddar om lager"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envanter yeniden yükleniyor"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Перезавантаження інвентарю"
+ }
+ }
+ }
+ },
+ "token_validity_deadline" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gültigkeitsfrist des Passworts:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password validity deadline:"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fecha límite de validez de la contraseña:"
+ }
+ },
+ "fi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salasanan voimassaolon määräaika:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Date limite de validité du mot de passe :"
+ }
+ },
+ "he" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "מועד תפוגת הסיסמה:"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scadenza validità password:"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Срок действия пароля:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lösenordets giltighetstid:"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Şifre geçerlilik son tarihi:"
+ }
+ },
+ "uk" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Кінцевий термін дії пароля:"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
+\ No newline at end of file
diff --git a/TalerPOS/Resources/Strings.swift b/TalerPOS/Resources/Strings.swift
@@ -0,0 +1,187 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import Foundation
+
+/// Localized string accessors.
+/// All accessors resolve from the active LanguageManager bundle so that
+/// in-app language changes apply immediately without restart.
+enum S {
+ private static var b: Bundle { LanguageManager.shared.bundle }
+ private static var t: String { "Localizable" }
+
+ private static func s(_ key: String) -> String {
+ NSLocalizedString(key, tableName: t, bundle: b, comment: "")
+ }
+
+ private static func s(_ key: String, _ arg: CVarArg) -> String {
+ String(format: NSLocalizedString(key, tableName: t, bundle: b, comment: ""), arg)
+ }
+
+ private static func s(_ key: String, _ arg1: CVarArg, _ arg2: CVarArg) -> String {
+ String(format: NSLocalizedString(key, tableName: t, bundle: b, comment: ""), arg1, arg2)
+ }
+
+ // MARK: - Menu / Navigation
+ static var menuOrder: String { s("menu_order") }
+ static var menuAmountEntry: String { s("menu_amount_entry") }
+ static var menuHistory: String { s("menu_history") }
+ static var menuSettings: String { s("menu_settings") }
+ static var menuReload: String { s("menu_reload") }
+
+ // MARK: - Products
+ static var productCategoryAll: String { s("product_category_all_objects") }
+ static var productCategoryUncategorized: String { s("product_category_uncategorized") }
+ static var productUnavailable: String { s("product_unavailable") }
+ static var productOutOfStock: String { s("product_out_of_stock") }
+ static var productWrongCurrency: String { s("product_wrong_currency") }
+
+ // MARK: - Order
+ static func orderLabelTitle(_ id: String) -> String { s("order_label_title", id) }
+ static func orderTotal(_ amount: String) -> String { s("order_total", amount) }
+ static var orderRestart: String { s("order_restart") }
+ static var orderUndo: String { s("order_undo") }
+ static var orderPrevious: String { s("order_previous") }
+ static var orderNext: String { s("order_next") }
+ static var orderCustom: String { s("order_custom") }
+ static var orderComplete: String { s("order_complete") }
+ static func orderCompleteWithAmount(_ amount: String) -> String { s("order_complete_with_amount", amount) }
+ static var orderDelete: String { s("order_delete") }
+ static var orderCustomProduct: String { s("order_custom_product") }
+ static var orderCustomAdd: String { s("order_custom_add_button") }
+ static var orderCustomProductDefault: String { s("order_custom_product_default") }
+
+ // MARK: - Amount Entry
+ static var amountEntryLabel: String { s("amount_entry_label") }
+ static var amountEntryCreateOrder: String { s("amount_entry_create_order_charge") }
+ static var amountEntryClear: String { s("amount_entry_clear") }
+ static var amountEntryProductDescription: String { s("amount_entry_product_description") }
+ static var amountEntryErrorZero: String { s("amount_entry_error_zero") }
+ static var amountEntryBackspace: String { s("amount_entry_backspace") }
+ static var amountEntryErrorWrongCurrency: String { s("amount_entry_error_wrong_currency") }
+
+ // MARK: - Config
+ static var configLabel: String { s("config_label") }
+ static var configMerchantUrl: String { s("config_merchant_url") }
+ static var configUsername: String { s("config_username") }
+ static var configPassword: String { s("config_password") }
+ static var configOk: String { s("config_ok") }
+ static var configAuthError: String { s("config_auth_error") }
+ static var configErrorNetwork: String { s("config_error_network") }
+ static var configErrorCategory: String { s("config_error_category") }
+ static var configErrorMalformed: String { s("config_error_malformed") }
+ static var configErrorUnknown: String { s("config_error_unknown") }
+ static var configFetching: String { s("config_fetching") }
+ static var configSavePassword: String { s("config_save_password") }
+
+ // MARK: - Payment
+ static var paymentIntro: String { s("payment_intro") }
+ static var paymentClaimed: String { s("payment_claimed") }
+ static var paymentCancel: String { s("payment_cancel") }
+ static var paymentReceived: String { s("payment_received") }
+ static var paymentBackButton: String { s("payment_back_button") }
+ static func paymentOrderId(_ id: String) -> String { s("payment_order_id", id) }
+ static var paymentProcessLabel: String { s("payment_process_label") }
+ static var paymentFetchingLink: String { s("payment_fetching_link") }
+ static var paymentShareUri: String { s("payment_share_uri") }
+ static var paymentCopyUri: String { s("payment_copy_uri") }
+ static var forceDeleteTitle: String { s("force_delete_dialog_title") }
+ static var forceDeleteMessage: String { s("force_delete_dialog_message") }
+ static var forceDeleteConfirm: String { s("force_delete_dialog_confirm") }
+
+ // MARK: - History
+ static var historyLabel: String { s("history_label") }
+ static var historyRefresh: String { s("history_refresh") }
+ static func historyRefNo(_ id: String) -> String { s("history_ref_no", id) }
+ static var historyUnpaid: String { s("history_status_unpaid") }
+ static var historyPaid: String { s("history_status_paid") }
+ static var historyPaymentPending: String { s("history_status_payment_pending") }
+ static var historyPaymentClaimed: String { s("history_status_payment_claimed") }
+ static var historyRefundPending: String { s("history_status_refund_pending") }
+ static var historyRefunded: String { s("history_status_refunded") }
+ static var historyShowPayment: String { s("history_show_payment") }
+ static var historyShowRefund: String { s("history_show_refund") }
+ static var historyRefund: String { s("history_refund") }
+
+ // MARK: - Refund
+ static var refundAmount: String { s("refund_amount") }
+ static var refundReason: String { s("refund_reason") }
+ static var refundAbort: String { s("refund_abort") }
+ static var refundConfirm: String { s("refund_confirm") }
+ static var refundErrorInvalidAmount: String { s("refund_error_invalid_amount") }
+ static var refundErrorZero: String { s("refund_error_zero") }
+ static var refundErrorDeadline: String { s("refund_error_deadline") }
+ static var refundErrorAlreadyRefunded: String { s("refund_error_already_refunded") }
+ static var refundIntro: String { s("refund_intro") }
+ static var refundStateMissing: String { s("refund_state_missing") }
+ static func refundErrorMaxAmount(_ amount: String) -> String { s("refund_error_max_amount", amount) }
+
+ // MARK: - Settings
+ static var settingsAppTitle: String { s("settings_app_title") }
+ static var settingsInitialOrderLabel: String { s("settings_initial_order_label") }
+ static var settingsLanguageLabel: String { s("settings_language_label") }
+ static var settingsLanguageSystemDefault: String { s("settings_language_system_default") }
+ static var settingsInstanceTitle: String { s("settings_instance_title") }
+ static var settingsInstanceButton: String { s("settings_instance_button") }
+ static var settingsLogoutButton: String { s("settings_logout_button") }
+ static var settingsInstanceDescription: String { s("settings_instance_description") }
+
+ // MARK: - Errors
+ static var errorHistory: String { s("error_history") }
+ static var errorPayment: String { s("error_payment") }
+ static var errorOrderCreation: String { s("error_order_creation") }
+ static var errorInventoryUnavailable: String { s("error_inventory_unavailable") }
+ static var errorDeleteOrder: String { s("error_delete_order") }
+
+ // MARK: - Common Dialogs
+ static var commonError: String { s("common_error") }
+ static var commonOk: String { s("common_ok") }
+
+ // MARK: - Config Validation
+ static var configErrorEmptyUrl: String { s("config_error_empty_url") }
+ static var configErrorEmptyToken: String { s("config_error_empty_token") }
+
+ // MARK: - Config Mode
+ static var configModeManual: String { s("config_mode_manual") }
+ static var configModeQr: String { s("config_mode_qr") }
+ static var configQrHint: String { s("config_qr_hint") }
+ static var configQrError: String { s("config_qr_error") }
+ static var configCameraPermission: String { s("config_camera_permission") }
+
+ // MARK: - MFA / Challenge
+ static var mfaChallengeTitle: String { s("mfa_challenge_title") }
+ static func mfaChallengeMessage(_ channel: String, _ info: String) -> String { s("mfa_challenge_message", channel, info) }
+ static var mfaChallengeCodeHint: String { s("mfa_challenge_code_hint") }
+ static func mfaChallengeCodeIncomplete(_ count: Int) -> String { s("mfa_challenge_code_incomplete", count) }
+ static var mfaChooseTitle: String { s("mfa_choose_title") }
+ static var mfaChallengeInvalid: String { s("mfa_challenge_invalid") }
+ static var mfaChallengeRetry: String { s("mfa_challenge_retry") }
+ static var mfaChallengeFailed: String { s("mfa_challenge_failed") }
+ static var mfaEnterCode: String { s("mfa_enter_code") }
+ static var mfaResendCode: String { s("mfa_resend_code") }
+ static var mfaVerify: String { s("mfa_verify") }
+ static var mfaCancel: String { s("mfa_cancel") }
+ static func mfaResendFailed(_ error: String) -> String { s("mfa_resend_failed", error) }
+ static func mfaRequestFailed(_ error: String) -> String { s("mfa_request_failed", error) }
+
+ // MARK: - Brand
+ static var appName: String { s("app_name") }
+ static var appNameShort: String { s("app_name_short") }
+ static var projectName: String { s("project_name") }
+
+ // MARK: - Session
+ static var sessionExpiredToast: String { s("session_expired_toast") }
+}
diff --git a/TalerPOS/Views/AmountEntryView.swift b/TalerPOS/Views/AmountEntryView.swift
@@ -0,0 +1,359 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+private let quickAmountProductId = "quick_amount"
+
+struct AmountEntryView: View {
+ @EnvironmentObject var configManager: ConfigManager
+ @EnvironmentObject var paymentManager: PaymentManager
+ @EnvironmentObject var orderManager: OrderManager
+ let onNavigate: (PosDestination) -> Void
+ var openDrawer: (() -> Void)?
+
+ @State private var amount: Amount = .zero("")
+ @State private var selectedCurrency: String = ""
+ @State private var errorMessage: String?
+
+ private var numInputDigits: Int {
+ configManager.currencySpec?.numFractionalInputDigits ?? 2
+ }
+
+ private var isTablet: Bool {
+ UIDevice.current.userInterfaceIdiom == .pad
+ }
+
+ private var amountText: String {
+ amount.amountStr(numDigits: numInputDigits)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PosTopBar(title: S.menuAmountEntry, onMenuTap: { openDrawer?() })
+
+ if isTablet {
+ tabletLayout
+ } else {
+ phoneLayout
+ }
+ }
+ .navigationBarHidden(true)
+ .onAppear {
+ if let currency = configManager.currency {
+ selectedCurrency = currency
+ amount = .zero(currency)
+ }
+ }
+ }
+
+ // MARK: - Phone: horizontal Row (amount 32% | keypad 68%)
+
+ private var phoneLayout: some View {
+ GeometryReader { geo in
+ HStack(spacing: 8) {
+ phoneAmountPane
+ .frame(width: geo.size.width * 0.32)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+
+ keypadPane(isCompact: geo.size.height <= 540)
+ .frame(width: geo.size.width * 0.68 - 16)
+ .padding(4)
+ }
+ .padding(8)
+ }
+ }
+
+ // MARK: - Tablet: vertical Column (amount 35% | keypad 65% at 60% width)
+
+ private var tabletLayout: some View {
+ GeometryReader { geo in
+ VStack(spacing: 12) {
+ tabletAmountPane
+ .frame(height: geo.size.height * 0.35)
+
+ HStack {
+ Spacer()
+ keypadPane(isCompact: false)
+ .frame(width: geo.size.width * 0.6)
+ Spacer()
+ }
+ .frame(height: geo.size.height * 0.65 - 12)
+ }
+ .padding(16)
+ }
+ }
+
+ // MARK: - Phone Amount Pane (vertical: amount text, currency dropdown)
+
+ private var phoneAmountPane: some View {
+ VStack(spacing: 12) {
+ phoneAmountText
+
+ currencyDropdown
+
+ Spacer()
+
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundColor(.posError)
+ }
+ }
+ }
+
+ private var phoneAmountText: some View {
+ let text = amountText
+ let len = text.count
+ let fontSize: CGFloat = {
+ if len <= 6 { return 56 }
+ if len <= 8 { return 48 }
+ if len <= 10 { return 40 }
+ if len <= 12 { return 32 }
+ if len <= 14 { return 26 }
+ return 22
+ }()
+ return Text(text)
+ .font(.system(size: fontSize, weight: .bold, design: .default))
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ // MARK: - Tablet Amount Pane (horizontal: amount text + currency dropdown)
+
+ private var tabletAmountPane: some View {
+ HStack(spacing: 12) {
+ Spacer()
+
+ Text(amountText)
+ .font(.system(size: 56, weight: .bold, design: .default))
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+
+ currencyDropdown
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ // MARK: - Currency Dropdown (OutlinedTextField-style)
+
+ private var currencyDropdown: some View {
+ Menu {
+ if let currency = configManager.currency {
+ Button(currency) {
+ selectCurrency(currency)
+ }
+ }
+ } label: {
+ HStack(spacing: 4) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(S.amountEntryLabel)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ Text(selectedCurrency.isEmpty ? "---" : selectedCurrency)
+ .font(.body)
+ .fontWeight(.medium)
+ .foregroundColor(.posOnSurface)
+ }
+ Image(systemName: "chevron.down")
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .frame(minWidth: 96)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(Color.posSurface)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(Color.posOutlineVariant, lineWidth: 1)
+ )
+ }
+ }
+
+ private func selectCurrency(_ currency: String) {
+ if selectedCurrency != currency {
+ selectedCurrency = currency
+ amount = .zero(currency)
+ errorMessage = nil
+ }
+ }
+
+ // MARK: - Keypad Pane
+
+ private func keypadPane(isCompact: Bool) -> some View {
+ let rowSpacing: CGFloat = isCompact ? 6 : 8
+ let digitFontSize: CGFloat = isCompact ? 24 : 28
+
+ return VStack(spacing: rowSpacing) {
+ ForEach(digitRows, id: \.self) { row in
+ HStack(spacing: rowSpacing) {
+ ForEach(row, id: \.self) { digit in
+ AmountKeyButton(
+ text: digit,
+ fontSize: digitFontSize,
+ action: { handleKey(digit) }
+ )
+ }
+ }
+ .frame(maxHeight: .infinity)
+ }
+
+ bottomRow(rowSpacing: rowSpacing, digitFontSize: digitFontSize, isCompact: isCompact)
+ .frame(maxHeight: .infinity)
+
+ chargeButton
+ }
+ }
+
+ private var digitRows: [[String]] {
+ [
+ ["1", "2", "3"],
+ ["4", "5", "6"],
+ ["7", "8", "9"],
+ ]
+ }
+
+ private func bottomRow(rowSpacing: CGFloat, digitFontSize: CGFloat, isCompact: Bool) -> some View {
+ let clearLabel = S.amountEntryClear
+ let clearFontSize: CGFloat = {
+ let len = clearLabel.count
+ if len >= 14 { return isCompact ? 12 : 14 }
+ if len >= 10 { return isCompact ? 14 : 16 }
+ return isCompact ? 16 : 20
+ }()
+
+ return HStack(spacing: rowSpacing) {
+ AmountKeyButton(
+ text: clearLabel,
+ fontSize: clearFontSize,
+ action: { handleKey("clear") }
+ )
+
+ AmountKeyButton(
+ text: "0",
+ fontSize: digitFontSize,
+ action: { handleKey("0") }
+ )
+
+ Button(action: { handleKey("backspace") }) {
+ Image(systemName: "delete.backward")
+ .font(.system(size: digitFontSize))
+ .foregroundColor(.posOnSecondaryContainer)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.posSecondaryContainer)
+ .cornerRadius(12)
+ }
+ .accessibilityLabel(S.amountEntryBackspace)
+ }
+ }
+
+ private var chargeButton: some View {
+ let canCharge = !amount.isZero && !selectedCurrency.isEmpty
+ return Button(action: createPayment) {
+ Text(S.amountEntryCreateOrder)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .frame(height: 56)
+ .background(canCharge ? Color.posPrimary : Color.posSecondary.opacity(0.12))
+ .foregroundColor(canCharge ? .posOnPrimary : .posOnSurface.opacity(0.38))
+ .cornerRadius(12)
+ }
+ .disabled(!canCharge)
+ }
+
+ // MARK: - Input
+
+ private func handleKey(_ key: String) {
+ errorMessage = nil
+ let currency = selectedCurrency.isEmpty ? (configManager.currency ?? "") : selectedCurrency
+ if amount.currency != currency {
+ amount = .zero(currency)
+ }
+ switch key {
+ case "clear":
+ amount = .zero(currency)
+ case "backspace":
+ amount = amount.removeInputDigit(numInputDigits: numInputDigits)
+ default:
+ if let digit = Int(key) {
+ if let newAmount = amount.addInputDigit(digit, numInputDigits: numInputDigits) {
+ amount = newAmount
+ }
+ }
+ }
+ }
+
+ // MARK: - Create Payment
+
+ private func createPayment() {
+ guard !amount.isZero else {
+ errorMessage = S.amountEntryErrorZero
+ return
+ }
+ guard let configuredCurrency = configManager.currency else {
+ onNavigate(.config)
+ return
+ }
+ if selectedCurrency != configuredCurrency {
+ errorMessage = S.amountEntryErrorWrongCurrency
+ return
+ }
+
+ let product = ConfigProduct(
+ productId: quickAmountProductId,
+ description: S.amountEntryProductDescription,
+ price: amount,
+ categories: [Int.min],
+ quantity: 1
+ )
+ let order = Order(
+ id: -1,
+ currency: configuredCurrency,
+ availableCategories: [:],
+ products: [product]
+ )
+ paymentManager.createPayment(order: order, includeProducts: false, origin: .amountEntry)
+ onNavigate(.processPayment)
+ }
+}
+
+// MARK: - Key Button
+
+private struct AmountKeyButton: View {
+ let text: String
+ let fontSize: CGFloat
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .font(.system(size: fontSize, weight: .semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.7)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.horizontal, 2)
+ .background(Color.posSecondaryContainer)
+ .foregroundColor(.posOnSecondaryContainer)
+ .cornerRadius(12)
+ }
+ }
+}
diff --git a/TalerPOS/Views/ConfigView.swift b/TalerPOS/Views/ConfigView.swift
@@ -0,0 +1,708 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+private let MFA_CODE_DIGITS = 8
+
+struct ChallengeSelectionData: Identifiable {
+ let id = UUID()
+ let challenges: [MfaChallenge]
+}
+
+private enum ConfigMode: String, CaseIterable {
+ case manual
+ case qr
+}
+
+struct ConfigView: View {
+ @EnvironmentObject var configManager: ConfigManager
+ let onConfigured: () -> Void
+
+ @State private var configMode: ConfigMode = .manual
+ @State private var merchantUrlText: String = ""
+ @State private var usernameText: String = ""
+ @State private var tokenText: String = ""
+ @State private var saveToken: Bool = true
+ @State private var isSubmitting: Bool = false
+ @State private var showPassword: Bool = false
+ @State private var errorMessage: String? = nil
+ @FocusState private var focusedField: ConfigField?
+
+ @State private var challengeSelectionData: ChallengeSelectionData? = nil
+ @State private var mfaChallenges: [MfaChallenge] = []
+ @State private var mfaCombiAnd = false
+ @State private var tanChallenge: MfaChallenge? = nil
+ @State private var tanDigits: [String] = Array(repeating: "", count: MFA_CODE_DIGITS)
+ @State private var tanErrorMessage: String? = nil
+ @State private var mfaSolvedChallengeIds: [String] = []
+ @State private var mfaBaseUrl: String = ""
+ @State private var mfaUsername: String = ""
+ @State private var mfaInitialSecret: String = ""
+ @State private var mfaRemainingChallenges: [MfaChallenge] = []
+
+ private enum ConfigField: Hashable {
+ case merchantUrl, username, token
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Picker("", selection: $configMode) {
+ Text(S.configModeManual).tag(ConfigMode.manual)
+ Text(S.configModeQr).tag(ConfigMode.qr)
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+
+ switch configMode {
+ case .manual:
+ manualConfigBody
+ case .qr:
+ qrConfigBody
+ }
+ }
+ .navigationTitle(S.configLabel)
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear { initializeState() }
+ .onChange(of: configManager.configResult) { result in
+ guard isSubmitting else { return }
+ switch result {
+ case .success:
+ isSubmitting = false
+ errorMessage = nil
+ onConfigured()
+ case .error(let msg):
+ isSubmitting = false
+ errorMessage = msg
+ case .none:
+ break
+ }
+ }
+ .sheet(item: $challengeSelectionData) { data in
+ challengeSelectionContent(challenges: data.challenges)
+ }
+ .sheet(item: $tanChallenge) { challenge in
+ TanInputSheet(
+ challenge: challenge,
+ digits: $tanDigits,
+ errorMessage: $tanErrorMessage,
+ onConfirm: { confirmTanCode() },
+ onCancel: {
+ tanChallenge = nil
+ isSubmitting = false
+ },
+ onResend: { resendChallenge() }
+ )
+ }
+ }
+
+ // MARK: - QR Config Mode
+
+ private var qrConfigBody: some View {
+ VStack(spacing: 16) {
+ Text(S.configQrHint)
+ .font(.subheadline)
+ .foregroundColor(.posOnSurfaceVariant)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+
+ QRScannerView { result in
+ handleQRResult(result)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .cornerRadius(12)
+ .padding(.horizontal, 20)
+
+ if let errorMessage = errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundColor(.posError)
+ .padding(.horizontal, 20)
+ }
+
+ Spacer().frame(height: 20)
+ }
+ }
+
+ private func handleQRResult(_ result: Result<String, Error>) {
+ switch result {
+ case .success(let scannedString):
+ if let parsed = parseTalerPosUri(scannedString) {
+ errorMessage = nil
+ isSubmitting = true
+ let configUrl = parsed.configUrl
+ let token = parsed.token
+ configManager.savePassword = true
+ configManager.fetchConfig(url: configUrl, token: token, save: true)
+ } else {
+ errorMessage = S.configQrError
+ }
+ case .failure:
+ errorMessage = S.configCameraPermission
+ }
+ }
+
+ private struct PosSetupUri {
+ let configUrl: String
+ let token: String
+ }
+
+ private func parseTalerPosUri(_ raw: String) -> PosSetupUri? {
+ guard raw.hasPrefix("taler-pos://") else { return nil }
+ let withScheme = "https://" + raw.dropFirst("taler-pos://".count)
+ guard let url = URL(string: withScheme) else { return nil }
+ let host = url.host ?? ""
+ let port = url.port.map { ":\($0)" } ?? ""
+ let path = url.path
+ let configUrl = "https://\(host)\(port)\(path)"
+ let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?.first(where: { $0.name == "token" })?.value ?? ""
+ guard !token.isEmpty else { return nil }
+ return PosSetupUri(configUrl: configUrl, token: token)
+ }
+
+ // MARK: - Manual Config Mode
+
+ private var manualConfigBody: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(S.configMerchantUrl)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ HStack {
+ Text("https://")
+ .foregroundColor(.posOnSurfaceVariant)
+ TextField("backend.example.com", text: $merchantUrlText)
+ .keyboardType(.URL)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ .focused($focusedField, equals: .merchantUrl)
+ .submitLabel(.next)
+ .onSubmit { focusedField = .username }
+ }
+ .padding(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.posOutline, lineWidth: 1)
+ )
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(S.configUsername)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ TextField("Instance name", text: $usernameText)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ .focused($focusedField, equals: .username)
+ .submitLabel(.next)
+ .onSubmit { focusedField = .token }
+ .padding(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.posOutline, lineWidth: 1)
+ )
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(S.configPassword)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ HStack {
+ Group {
+ if showPassword {
+ TextField("", text: $tokenText)
+ } else {
+ SecureField("", text: $tokenText)
+ }
+ }
+ .focused($focusedField, equals: .token)
+ .submitLabel(.done)
+ .onSubmit { focusedField = nil }
+
+ Button {
+ showPassword.toggle()
+ } label: {
+ Image(systemName: showPassword ? "eye.fill" : "eye.slash.fill")
+ .foregroundColor(.posOnSurfaceVariant)
+ }
+ }
+ .padding(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.posOutline, lineWidth: 1)
+ )
+ }
+
+ HStack {
+ Toggle(S.configSavePassword, isOn: $saveToken)
+ .labelsHidden()
+ Text(S.configSavePassword)
+ .font(.subheadline)
+
+ Spacer()
+
+ Button(action: submitManualConfig) {
+ if isSubmitting {
+ ProgressView()
+ .padding(.horizontal, 8)
+ } else {
+ Text(S.configOk)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(isSubmitting)
+ }
+
+ if let errorMessage = errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundColor(.posError)
+ .padding(.top, 4)
+ }
+ }
+ .padding(20)
+ }
+ }
+
+ // MARK: - Challenge selection
+
+ private func challengeSelectionContent(challenges: [MfaChallenge]) -> some View {
+ NavigationStack {
+ List {
+ Section {
+ Text(S.mfaChooseTitle)
+ .font(.subheadline)
+ .foregroundColor(.posOnSurfaceVariant)
+ .listRowBackground(Color.clear)
+ }
+
+ Section {
+ ForEach(challenges, id: \.challengeId) { challenge in
+ Button {
+ challengeSelectionData = nil
+ handleSelectedChallenge(challenge)
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: challenge.tanChannel == .sms ? "message.fill" : "envelope.fill")
+ .foregroundColor(.posPrimary)
+ .frame(width: 28)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(challenge.tanChannel == .sms ? "SMS" : "Email")
+ .font(.body.weight(.semibold))
+ .foregroundColor(.posOnSurface)
+ Text(challenge.tanInfo)
+ .font(.subheadline)
+ .foregroundColor(.posOnSurfaceVariant)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+ .navigationTitle(S.mfaChallengeTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(S.mfaCancel) {
+ challengeSelectionData = nil
+ isSubmitting = false
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium, .large])
+ }
+
+ private func initializeState() {
+ merchantUrlText = configManager.merchantUrl
+ saveToken = configManager.savePassword
+
+ if !merchantUrlText.isEmpty {
+ sanitizeMerchantUrlAndUpdateFields()
+ }
+ }
+
+ private func submitManualConfig() {
+ focusedField = nil
+ isSubmitting = true
+ errorMessage = nil
+
+ let baseUrl = sanitizeMerchantUrlAndUpdateFields()
+ guard !baseUrl.isEmpty else {
+ isSubmitting = false
+ errorMessage = S.configErrorEmptyUrl
+ return
+ }
+
+ let username = usernameText.trimmingCharacters(in: .whitespaces)
+ let token = tokenText.trimmingCharacters(in: .whitespaces)
+
+ guard !token.isEmpty else {
+ isSubmitting = false
+ errorMessage = S.configErrorEmptyToken
+ return
+ }
+
+ if !username.isEmpty {
+ mfaBaseUrl = baseUrl
+ mfaUsername = username
+ mfaInitialSecret = token
+ mfaSolvedChallengeIds = []
+ fetchLimitedAccessTokenWithMfa()
+ } else {
+ let configUrl = baseUrl
+ configManager.savePassword = saveToken
+ configManager.fetchConfig(url: configUrl, token: token, save: true)
+ }
+ }
+
+ private func fetchLimitedAccessTokenWithMfa() {
+ Task {
+ do {
+ let limitedToken = try await configManager.fetchLimitedAccessToken(
+ baseUrl: mfaBaseUrl,
+ username: mfaUsername,
+ initialSecret: mfaInitialSecret,
+ challengeIds: mfaSolvedChallengeIds
+ )
+
+ let configUrl = "\(mfaBaseUrl)/instances/\(mfaUsername)"
+ await MainActor.run {
+ configManager.savePassword = saveToken
+ configManager.fetchConfig(url: configUrl, token: limitedToken, save: true)
+ }
+ } catch let error as ChallengeRequiredError {
+ await MainActor.run {
+ handleChallengeResponse(error.challengeResponse)
+ }
+ } catch {
+ await MainActor.run {
+ isSubmitting = false
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+ }
+
+ private func handleChallengeResponse(_ response: ChallengesResponse) {
+ mfaChallenges = response.challenges
+ mfaCombiAnd = response.combiAnd
+
+ if mfaCombiAnd {
+ mfaRemainingChallenges = response.challenges
+ solveNextChallenge()
+ } else {
+ if response.challenges.count == 1 {
+ handleSelectedChallenge(response.challenges[0])
+ } else {
+ challengeSelectionData = ChallengeSelectionData(challenges: response.challenges)
+ }
+ }
+ }
+
+ private func solveNextChallenge() {
+ guard let next = mfaRemainingChallenges.first else {
+ fetchLimitedAccessTokenWithMfa()
+ return
+ }
+ handleSelectedChallenge(next)
+ }
+
+ private func handleSelectedChallenge(_ challenge: MfaChallenge) {
+ tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS)
+ tanErrorMessage = nil
+
+ Task {
+ do {
+ try await configManager.requestChallenge(
+ baseUrl: mfaBaseUrl,
+ username: mfaUsername,
+ challengeId: challenge.challengeId
+ )
+ await MainActor.run {
+ tanChallenge = challenge
+ }
+ } catch {
+ await MainActor.run {
+ isSubmitting = false
+ errorMessage = S.mfaRequestFailed(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+ private func confirmTanCode() {
+ guard let challenge = tanChallenge else { return }
+ let code = tanDigits.joined()
+ let digitsOnly = code.filter(\.isNumber)
+
+ guard digitsOnly.count == MFA_CODE_DIGITS else {
+ tanErrorMessage = S.mfaChallengeCodeIncomplete(MFA_CODE_DIGITS)
+ return
+ }
+
+ tanErrorMessage = nil
+
+ Task {
+ do {
+ try await configManager.confirmChallenge(
+ baseUrl: mfaBaseUrl,
+ username: mfaUsername,
+ challengeId: challenge.challengeId,
+ tan: digitsOnly
+ )
+
+ await MainActor.run {
+ mfaSolvedChallengeIds.append(challenge.challengeId)
+ tanChallenge = nil
+
+ if mfaCombiAnd {
+ mfaRemainingChallenges.removeAll { $0.challengeId == challenge.challengeId }
+ solveNextChallenge()
+ } else {
+ fetchLimitedAccessTokenWithMfa()
+ }
+ }
+ } catch let error as ApiError {
+ await MainActor.run {
+ switch error {
+ case .http(409, _, _):
+ tanErrorMessage = S.mfaChallengeInvalid
+ tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS)
+ case .http(429, _, _):
+ tanErrorMessage = S.mfaChallengeRetry
+ default:
+ tanChallenge = nil
+ isSubmitting = false
+ errorMessage = error.localizedDescription
+ }
+ }
+ } catch {
+ await MainActor.run {
+ tanChallenge = nil
+ isSubmitting = false
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+ }
+
+ private func resendChallenge() {
+ guard let challenge = tanChallenge else { return }
+ tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS)
+ tanErrorMessage = nil
+
+ Task {
+ do {
+ try await configManager.requestChallenge(
+ baseUrl: mfaBaseUrl,
+ username: mfaUsername,
+ challengeId: challenge.challengeId
+ )
+ await MainActor.run {
+ tanErrorMessage = nil
+ }
+ } catch {
+ await MainActor.run {
+ tanErrorMessage = S.mfaResendFailed(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+ @discardableResult
+ private func sanitizeMerchantUrlAndUpdateFields() -> String {
+ let rawInput = merchantUrlText.trimmingCharacters(in: .whitespaces)
+ if rawInput.isEmpty { return "" }
+
+ let normalizedInput: String
+ if rawInput.hasPrefix("http://") || rawInput.hasPrefix("https://") {
+ normalizedInput = rawInput
+ } else {
+ normalizedInput = "https://\(rawInput)"
+ }
+
+ guard let url = URL(string: normalizedInput) else { return "" }
+
+ let host = url.host ?? ""
+ let port = url.port.map { ":\($0)" } ?? ""
+ let baseHost = "\(host)\(port)"
+
+ let segments = url.pathComponents.filter { $0 != "/" }
+ if segments.count >= 2,
+ segments[0].caseInsensitiveCompare("instances") == .orderedSame {
+ usernameText = segments[1]
+ }
+
+ merchantUrlText = baseHost
+ return baseHost.isEmpty ? "" : "https://\(baseHost)"
+ }
+}
+
+// MARK: - TAN Input Sheet
+
+private struct TanInputSheet: View {
+ let challenge: MfaChallenge
+ @Binding var digits: [String]
+ @Binding var errorMessage: String?
+ let onConfirm: () -> Void
+ let onCancel: () -> Void
+ let onResend: () -> Void
+
+ @State private var codeText: String = ""
+ @FocusState private var isCodeFieldFocused: Bool
+
+ private var channelLabel: String {
+ switch challenge.tanChannel {
+ case .sms: return "SMS"
+ case .email: return "Email"
+ }
+ }
+
+ private var channelInfo: String {
+ challenge.tanInfo
+ }
+
+ private var isComplete: Bool {
+ codeText.filter(\.isNumber).count == MFA_CODE_DIGITS
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 20) {
+ Text(S.mfaEnterCode)
+ .font(.headline)
+ .padding(.top, 20)
+
+ Text(S.mfaChallengeMessage(channelLabel, channelInfo))
+ .font(.subheadline)
+ .foregroundColor(.posOnSurfaceVariant)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+
+ ZStack {
+ TextField("", text: $codeText)
+ .keyboardType(.numberPad)
+ .textContentType(.none)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .focused($isCodeFieldFocused)
+ .foregroundColor(.clear)
+ .tint(.clear)
+ .accentColor(.clear)
+ .onChange(of: codeText) { newValue in
+ let digitsOnly = newValue.filter(\.isNumber)
+ let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS))
+ if clamped != newValue {
+ codeText = clamped
+ }
+ let chars = Array(clamped)
+ for i in 0..<MFA_CODE_DIGITS {
+ digits[i] = i < chars.count ? String(chars[i]) : ""
+ }
+ }
+
+ digitBoxesView
+ .allowsHitTesting(false)
+ }
+ .contentShape(Rectangle())
+ .onTapGesture { isCodeFieldFocused = true }
+
+ if let errorMessage = errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundColor(.posError)
+ }
+
+ Button(S.mfaResendCode) { onResend() }
+ .font(.subheadline)
+ .foregroundColor(.posPrimary)
+
+ Spacer()
+
+ Button(action: onConfirm) {
+ Text(S.mfaVerify)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!isComplete)
+ .padding(.horizontal)
+ .padding(.bottom, 20)
+ }
+ .padding()
+ .navigationTitle(S.mfaChallengeCodeHint)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(S.mfaCancel) { onCancel() }
+ }
+ }
+ .onAppear {
+ codeText = digits.joined()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ isCodeFieldFocused = true
+ }
+ }
+ .onChange(of: digits) { newDigits in
+ let joined = newDigits.joined()
+ if joined != codeText {
+ codeText = joined
+ }
+ }
+ }
+ .presentationDetents([.large])
+ }
+
+ private var digitBoxesView: some View {
+ HStack(spacing: 6) {
+ ForEach(0..<MFA_CODE_DIGITS, id: \.self) { index in
+ if index == 4 {
+ Text("-")
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundColor(.posOnSurfaceVariant)
+ }
+
+ let digitChar = index < codeText.count
+ ? String(codeText[codeText.index(codeText.startIndex, offsetBy: index)])
+ : ""
+ let isCurrent = codeText.count == index
+
+ Text(digitChar.isEmpty ? " " : digitChar)
+ .font(.title2)
+ .fontWeight(.bold)
+ .frame(width: 36, height: 48)
+ .background(Color.posSurface)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(isCurrent ? Color.posPrimary : Color.posOutline, lineWidth: isCurrent ? 2 : 1)
+ )
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Views/HistoryView.swift b/TalerPOS/Views/HistoryView.swift
@@ -0,0 +1,296 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+
+enum HistoryStatus {
+ case unpaid
+ case paid
+ case paymentPending
+ case paymentClaimed
+ case refundPending
+ case refunded
+
+ var label: String {
+ switch self {
+ case .unpaid: return S.historyUnpaid
+ case .paid: return S.historyPaid
+ case .paymentPending: return S.historyPaymentPending
+ case .paymentClaimed: return S.historyPaymentClaimed
+ case .refundPending: return S.historyRefundPending
+ case .refunded: return S.historyRefunded
+ }
+ }
+
+ var backgroundColor: Color {
+ switch self {
+ case .unpaid: return .posErrorContainer
+ case .paid: return .posPrimaryContainer
+ case .paymentPending: return .posSecondaryContainer
+ case .paymentClaimed: return .posTertiaryContainer
+ case .refundPending: return .posSecondaryContainer
+ case .refunded: return .posTertiaryContainer
+ }
+ }
+
+ var foregroundColor: Color {
+ switch self {
+ case .unpaid: return .posOnErrorContainer
+ case .paid: return .posOnPrimaryContainer
+ case .paymentPending: return .posOnSecondaryContainer
+ case .paymentClaimed: return .posOnTertiaryContainer
+ case .refundPending: return .posOnSecondaryContainer
+ case .refunded: return .posOnTertiaryContainer
+ }
+ }
+}
+
+struct HistoryView: View {
+ @EnvironmentObject var historyManager: HistoryManager
+ @EnvironmentObject var refundManager: RefundManager
+ @EnvironmentObject var paymentManager: PaymentManager
+ @EnvironmentObject var configManager: ConfigManager
+ let onNavigate: (PosDestination) -> Void
+ var openDrawer: (() -> Void)?
+ @State private var showRefundStateMissing = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PosTopBar(title: S.menuHistory, onMenuTap: { openDrawer?() }) {
+ Button(action: { historyManager.fetchHistory() }) {
+ Text(S.historyRefresh)
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 14)
+ .frame(minHeight: 40)
+ .background(Color.posSecondaryContainer)
+ .foregroundColor(.posOnSecondaryContainer)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .buttonStyle(.plain)
+ }
+
+ if historyManager.isLoading && historyManager.items.isEmpty {
+ Spacer()
+ ProgressView(S.configFetching)
+ Spacer()
+ } else if historyManager.items.isEmpty {
+ Spacer()
+ VStack(spacing: 16) {
+ Image(systemName: "clock")
+ .font(.system(size: 48))
+ .foregroundColor(.secondary)
+ Text(S.historyLabel)
+ .font(.title3)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(Array(historyManager.items.enumerated()), id: \.element.orderId) { index, item in
+ HistoryItemCard(
+ item: item,
+ hasPendingRefund: refundManager.pendingRefundOrderId == item.orderId,
+ activePayment: paymentManager.payment?.orderId == item.orderId ? paymentManager.payment : nil,
+ onRefundClicked: {
+ refundManager.startRefund(item: item)
+ onNavigate(.refund(item))
+ },
+ onDeleteClicked: {
+ historyManager.deleteOrder(orderId: item.orderId)
+ },
+ onShowPaymentClicked: {
+ paymentManager.resumePayment(item: item)
+ onNavigate(.processPayment)
+ },
+ onShowRefundClicked: {
+ if refundManager.resumeRefund(item: item) {
+ onNavigate(.refundUri)
+ } else {
+ showRefundStateMissing = true
+ historyManager.fetchHistory()
+ }
+ }
+ )
+ .onAppear {
+ historyManager.loadMoreHistoryIfNeeded(lastVisibleIndex: index)
+ }
+ }
+
+ if historyManager.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .padding(.vertical, 8)
+ }
+ }
+ .padding(16)
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ .onAppear {
+ print("[HistoryView] onAppear items=\(historyManager.items.count) isLoading=\(historyManager.isLoading)")
+ if configManager.merchantConfig?.baseUrl == nil {
+ onNavigate(.config)
+ } else {
+ historyManager.fetchHistory()
+ }
+ }
+ .alert(S.forceDeleteTitle, isPresented: .init(
+ get: { historyManager.forceDeleteOrderId != nil },
+ set: { if !$0 { historyManager.clearForceDeletePrompt() } }
+ )) {
+ Button(S.forceDeleteConfirm, role: .destructive) {
+ if let orderId = historyManager.forceDeleteOrderId {
+ historyManager.forceDeleteOrder(orderId)
+ }
+ }
+ Button(S.paymentCancel, role: .cancel) {
+ historyManager.clearForceDeletePrompt()
+ }
+ } message: {
+ Text(S.forceDeleteMessage)
+ }
+ .alert(S.errorHistory, isPresented: .init(
+ get: { historyManager.errorMessage != nil },
+ set: { if !$0 { historyManager.clearError() } }
+ )) {
+ Button(S.commonOk) { historyManager.clearError() }
+ } message: {
+ Text(historyManager.errorMessage ?? "")
+ }
+ .alert(S.commonError, isPresented: $showRefundStateMissing) {
+ Button(S.commonOk) {}
+ } message: {
+ Text(S.refundStateMissing)
+ }
+ }
+}
+
+// MARK: - HistoryItemCard
+
+struct HistoryItemCard: View {
+ let item: OrderHistoryEntry
+ let hasPendingRefund: Bool
+ let activePayment: Payment?
+ let onRefundClicked: () -> Void
+ let onDeleteClicked: () -> Void
+ let onShowPaymentClicked: () -> Void
+ let onShowRefundClicked: () -> Void
+
+ private var status: HistoryStatus {
+ if item.hasPendingRefund { return .refundPending }
+ if item.hasRefund { return .refunded }
+ if hasPendingRefund { return .refundPending }
+ if activePayment?.paid == true { return .paid }
+ if activePayment?.claimed == true { return .paymentClaimed }
+ if activePayment?.orderId == item.orderId && activePayment?.error == nil { return .paymentPending }
+ if item.paid { return .paid }
+ return .unpaid
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.summary)
+ .font(.headline)
+ Text(item.amount.readableAmount)
+ .font(.subheadline)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 6) {
+ HistoryStatusBadge(status: status)
+
+ if item.hasPendingRefund, let pendingAmount = item.pendingRefundAmount, pendingAmount.isZero == false {
+ HistoryAmountBadge(amount: pendingAmount.readableAmount, status: status)
+ } else if item.hasRefund, let refundAmount = item.refundAmount {
+ HistoryAmountBadge(amount: refundAmount.readableAmount, status: status)
+ }
+ }
+ }
+
+ Text(item.timestamp.date, style: .relative)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ Text(S.historyRefNo(item.orderId))
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ HStack(spacing: 8) {
+ if item.hasPendingRefund || hasPendingRefund {
+ Button(S.historyShowRefund, action: onShowRefundClicked)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ } else if item.refundable {
+ Button(S.historyRefund, action: onRefundClicked)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ } else if !item.paid {
+ Button(S.historyShowPayment, action: onShowPaymentClicked)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ Button(S.orderDelete, action: onDeleteClicked)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ }
+ .padding(16)
+ .background(Color.posSurface)
+ .cornerRadius(12)
+ }
+}
+
+struct HistoryStatusBadge: View {
+ let status: HistoryStatus
+
+ var body: some View {
+ Text(status.label)
+ .font(.caption)
+ .fontWeight(.medium)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(status.backgroundColor)
+ .foregroundColor(status.foregroundColor)
+ .cornerRadius(8)
+ }
+}
+
+struct HistoryAmountBadge: View {
+ let amount: String
+ let status: HistoryStatus
+
+ var body: some View {
+ Text(amount)
+ .font(.caption)
+ .fontWeight(.medium)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(status.backgroundColor)
+ .foregroundColor(status.foregroundColor)
+ .cornerRadius(8)
+ }
+}
diff --git a/TalerPOS/Views/OrderView.swift b/TalerPOS/Views/OrderView.swift
@@ -0,0 +1,568 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+struct OrderView: View {
+ @EnvironmentObject var orderManager: OrderManager
+ @EnvironmentObject var paymentManager: PaymentManager
+ @EnvironmentObject var configManager: ConfigManager
+ let onNavigate: (PosDestination) -> Void
+ var openDrawer: (() -> Void)?
+
+ @State private var showCustomProduct = false
+ @State private var refreshTimer: Timer?
+
+ private var liveOrder: LiveOrder? { orderManager.currentLiveOrder }
+ private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ orderTopBar
+ threeColumnLayout
+ }
+ .navigationBarHidden(true)
+ .onAppear { startInventoryRefresh() }
+ .onDisappear { stopInventoryRefresh() }
+ .sheet(isPresented: $showCustomProduct) {
+ CustomProductDialog(currency: orderManager.currentLiveOrder?.order.currency ?? configManager.currency ?? "") { product in
+ if let orderId = liveOrder?.id {
+ orderManager.addProduct(orderId: orderId, product: product)
+ }
+ }
+ }
+ }
+
+ private var orderTopBar: some View {
+ PosTopBar(
+ title: liveOrder.map { S.orderLabelTitle("\($0.id)") } ?? "Order",
+ onMenuTap: { openDrawer?() }
+ ) {
+ HStack(spacing: 8) {
+ topBarButton(
+ label: liveOrder?.restartState == .undo ? S.orderUndo : S.orderRestart,
+ enabled: liveOrder?.restartState != .disabled || liveOrder?.order.products.isEmpty == false
+ ) {
+ liveOrder?.restartOrUndo()
+ }
+
+ topBarButton(
+ label: S.orderPrevious,
+ enabled: orderManager.hasPreviousOrder()
+ ) {
+ orderManager.previousOrder()
+ }
+
+ topBarButton(
+ label: S.orderNext,
+ enabled: orderManager.hasNextOrder()
+ ) {
+ orderManager.nextOrder()
+ }
+
+ topBarButton(
+ label: S.menuReload,
+ enabled: true
+ ) {
+ configManager.reloadConfig()
+ }
+ }
+ }
+ }
+
+ private func topBarButton(label: String, enabled: Bool, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Text(label)
+ .font(.subheadline)
+ .fontWeight(.medium)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 14)
+ .frame(minHeight: 40)
+ .background(enabled ? Color.posSecondaryContainer : Color.posSecondaryContainer.opacity(0.4))
+ .foregroundColor(enabled ? .posOnSecondaryContainer : .posOnSecondaryContainer.opacity(0.4))
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .buttonStyle(.plain)
+ .disabled(!enabled)
+ }
+
+ private var threeColumnLayout: some View {
+ GeometryReader { geo in
+ let catWeight: CGFloat = isTablet ? 0.25 : 0.22
+ let prodWeight: CGFloat = isTablet ? 0.50 : 0.43
+ let orderWeight: CGFloat = isTablet ? 0.25 : 0.35
+ let compact = !isTablet
+
+ HStack(spacing: 0) {
+ CategoriesPane(
+ categories: orderManager.categories,
+ onSelect: { orderManager.selectCategory($0) }
+ )
+ .frame(width: geo.size.width * catWeight)
+
+ Color.posOutlineVariant.frame(width: 1)
+
+ ProductsPane(
+ products: orderManager.products,
+ currencySpec: configManager.currencySpec,
+ compact: compact,
+ onTap: { product in
+ if let orderId = liveOrder?.id {
+ orderManager.addProduct(orderId: orderId, product: product)
+ }
+ }
+ )
+ .frame(width: geo.size.width * prodWeight)
+
+ Color.posOutlineVariant.frame(width: 1)
+
+ OrderColumnPane(
+ liveOrder: liveOrder,
+ currencySpec: configManager.currencySpec,
+ compact: compact,
+ onSelectLine: { liveOrder?.selectOrderLine($0) },
+ onIncrease: { liveOrder?.increaseSelectedOrderLine() },
+ onDecrease: { liveOrder?.decreaseSelectedOrderLine() },
+ onCustom: { showCustomProduct = true },
+ onComplete: {
+ guard let lo = liveOrder, !lo.order.products.isEmpty else { return }
+ paymentManager.createPayment(order: lo.order)
+ onNavigate(.processPayment)
+ }
+ )
+ .frame(width: geo.size.width * orderWeight)
+ }
+ .padding(.horizontal, isTablet ? 0 : 8)
+ .padding(.vertical, isTablet ? 0 : 10)
+ }
+ }
+
+ private func startInventoryRefresh() {
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
+ configManager.refreshInventory()
+ }
+ }
+
+ private func stopInventoryRefresh() {
+ refreshTimer?.invalidate()
+ refreshTimer = nil
+ }
+}
+
+struct CategoriesPane: View {
+ let categories: [Category]
+ let onSelect: (Category) -> Void
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 6) {
+ ForEach(categories) { category in
+ Button {
+ onSelect(category)
+ } label: {
+ Text(category.localizedName)
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .padding(.horizontal, 8)
+ .background(category.selected ? Color.posSecondary : Color.posSecondaryContainer)
+ .foregroundColor(category.selected ? .posOnSecondary : .posOnSecondaryContainer)
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(Color.posOutlineVariant, lineWidth: 1))
+ }
+ }
+ }
+ .padding(8)
+ }
+ }
+}
+
+struct ProductsPane: View {
+ let products: [ConfigProduct]
+ var currencySpec: CurrencySpecification?
+ var compact: Bool = false
+ let onTap: (ConfigProduct) -> Void
+
+ var body: some View {
+ GeometryReader { geo in
+ let spacing: CGFloat = compact ? 6 : 8
+ let minTileWidth: CGFloat = compact ? 96 : 150
+ let columns = max(1, Int((geo.size.width + spacing) / (minTileWidth + spacing)))
+ let rows = products.chunked(into: columns)
+
+ ScrollView {
+ VStack(spacing: spacing) {
+ ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
+ let rowHasImage = row.contains { $0.image != nil && !$0.image!.isEmpty }
+ HStack(spacing: spacing) {
+ ForEach(row) { product in
+ ProductCard(
+ product: product,
+ currencySpec: currencySpec,
+ compact: compact,
+ rowHasImage: rowHasImage
+ ) {
+ onTap(product)
+ }
+ }
+ if row.count < columns {
+ ForEach(0..<(columns - row.count), id: \.self) { _ in
+ Color.clear.frame(maxWidth: .infinity)
+ }
+ }
+ }
+ }
+ }
+ .padding(compact ? 4 : 8)
+ .padding(.top, compact ? 8 : 12)
+ }
+ }
+ }
+}
+
+struct ProductCard: View {
+ let product: ConfigProduct
+ var currencySpec: CurrencySpecification?
+ var compact: Bool = false
+ var rowHasImage: Bool = false
+ let onTap: () -> Void
+
+ private var imageSize: CGFloat { compact ? 40 : 64 }
+
+ private var unavailableReason: String? {
+ guard !product.availableToSell else { return nil }
+ if product.currencyMismatch { return S.productWrongCurrency }
+ if product.remainingStock == 0 { return S.productOutOfStock }
+ return S.productUnavailable
+ }
+
+ var body: some View {
+ Button(action: onTap) {
+ VStack(spacing: compact ? 3 : 4) {
+ if let uiImage = product.decodedImage {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: imageSize, height: imageSize)
+ .frame(maxWidth: .infinity)
+ } else if rowHasImage {
+ Spacer()
+ .frame(height: imageSize)
+ .frame(maxWidth: .infinity)
+ }
+
+ Text(product.displayName)
+ .font(compact ? .caption : .subheadline)
+ .fontWeight(.bold)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .frame(maxWidth: .infinity)
+
+ if let desc = product.displayDescription {
+ Text(desc)
+ .font(compact ? .caption2 : .caption)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .frame(maxWidth: .infinity)
+ }
+
+ Spacer(minLength: 0)
+
+ Text(product.price.readableAmount(spec: currencySpec))
+ .font(compact ? .caption : .subheadline)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+
+ if let reason = unavailableReason {
+ Text(reason)
+ .font(compact ? .caption2 : .caption)
+ .fontWeight(.bold)
+ .foregroundColor(.posError)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(compact ? 6 : 8)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(product.availableToSell ? Color.posSurface : Color.posSurfaceVariant)
+ .cornerRadius(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .stroke(
+ product.availableToSell ? Color.posOutlineVariant : Color.posError.opacity(0.4),
+ lineWidth: 1
+ )
+ )
+ .shadow(color: .black.opacity(0.04), radius: 1, y: 1)
+ }
+ .buttonStyle(.plain)
+ .disabled(!product.availableToSell)
+ }
+}
+
+struct OrderColumnPane: View {
+ let liveOrder: LiveOrder?
+ var currencySpec: CurrencySpecification?
+ var compact: Bool = false
+ let onSelectLine: (ConfigProduct?) -> Void
+ let onIncrease: () -> Void
+ let onDecrease: () -> Void
+ let onCustom: () -> Void
+ let onComplete: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if let lo = liveOrder {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(lo.order.products) { product in
+ OrderRow(
+ product: product,
+ currencySpec: currencySpec,
+ isSelected: lo.selectedProductKey == product.id,
+ compact: compact
+ ) {
+ onSelectLine(lo.selectedProductKey == product.id ? nil : product)
+ }
+ Color.posOutlineVariant.frame(height: 1)
+ }
+ }
+ }
+
+ Color.posOutlineVariant.frame(height: 1)
+
+ OrderActionBar(
+ modifyAllowed: lo.modifyOrderAllowed,
+ increaseAllowed: lo.increaseOrderAllowed,
+ hasProducts: !lo.order.products.isEmpty,
+ totalAmount: lo.order.total.readableAmount(spec: currencySpec),
+ compact: compact,
+ onIncrease: onIncrease,
+ onDecrease: onDecrease,
+ onCustom: onCustom,
+ onComplete: onComplete
+ )
+ }
+ }
+ }
+}
+
+struct OrderRow: View {
+ let product: ConfigProduct
+ var currencySpec: CurrencySpecification?
+ let isSelected: Bool
+ var compact: Bool = false
+ let onTap: () -> Void
+
+ private var imageSize: CGFloat { compact ? 28 : 36 }
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: compact ? 6 : 8) {
+ Text("\(product.quantity)")
+ .font(compact ? .caption : .subheadline)
+ .fontWeight(.semibold)
+ .frame(width: 24, alignment: .trailing)
+
+ if let uiImage = product.decodedImage {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: imageSize, height: imageSize)
+ } else {
+ Color.clear.frame(width: imageSize, height: imageSize)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(product.displayName)
+ .font(compact ? .caption : .subheadline)
+ .lineLimit(2)
+ if let desc = product.displayDescription {
+ Text(desc)
+ .font(compact ? .caption2 : .caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer()
+
+ Text((product.price * product.quantity).readableAmount(spec: currencySpec))
+ .font(compact ? .caption : .subheadline)
+ .fontWeight(.medium)
+ }
+ .padding(.horizontal, compact ? 6 : 10)
+ .padding(.vertical, compact ? 4 : 8)
+ .background(isSelected ? Color.posSecondaryContainer : Color.clear)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct OrderActionBar: View {
+ let modifyAllowed: Bool
+ let increaseAllowed: Bool
+ let hasProducts: Bool
+ var totalAmount: String = ""
+ var compact: Bool = false
+ let onIncrease: () -> Void
+ let onDecrease: () -> Void
+ let onCustom: () -> Void
+ let onComplete: () -> Void
+
+ var body: some View {
+ VStack(spacing: compact ? 8 : 12) {
+ HStack(spacing: compact ? 8 : 12) {
+ orderControlButton(label: "+1", enabled: modifyAllowed && increaseAllowed, action: onIncrease)
+ orderControlButton(label: "-1", enabled: modifyAllowed, action: onDecrease)
+ Button(action: onCustom) {
+ Image(systemName: "square.grid.3x3")
+ .font(compact ? .body : .title3)
+ .frame(maxWidth: .infinity)
+ .frame(height: 44)
+ .background(Color.posPrimaryContainer)
+ .foregroundColor(.posOnPrimaryContainer)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(S.orderCustom)
+ }
+
+ Button(action: onComplete) {
+ Text(hasProducts ? S.orderCompleteWithAmount(totalAmount) : S.orderComplete)
+ .font(compact ? .body : .title3)
+ .fontWeight(.bold)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .frame(height: compact ? 72 : 96)
+ .background(hasProducts ? Color.posPrimary : Color.posSurfaceVariant)
+ .foregroundColor(hasProducts ? .posOnPrimary : .posOnSurfaceVariant)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ }
+ .buttonStyle(.plain)
+ .disabled(!hasProducts)
+ }
+ .padding(12)
+ }
+
+ private func orderControlButton(label: String, enabled: Bool, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Text(label)
+ .font(compact ? .body : .title3)
+ .fontWeight(.medium)
+ .frame(maxWidth: .infinity)
+ .frame(height: 44)
+ .background(enabled ? Color.posPrimaryContainer : Color.posSurfaceVariant)
+ .foregroundColor(enabled ? .posOnPrimaryContainer : .posOnSurfaceVariant)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .buttonStyle(.plain)
+ .disabled(!enabled)
+ }
+}
+
+private extension Array {
+ func chunked(into size: Int) -> [[Element]] {
+ stride(from: 0, to: count, by: size).map {
+ Array(self[$0..<Swift.min($0 + size, count)])
+ }
+ }
+}
+
+struct CustomProductDialog: View {
+ let currency: String
+ let onAdd: (ConfigProduct) -> Void
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = S.orderCustomProductDefault
+ @State private var priceString = ""
+ @State private var priceError: String?
+
+ private var parsedPrice: Amount? {
+ Amount.fromString(currency, priceString)
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(S.orderCustomProduct)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ TextField(S.orderCustomProduct, text: $name)
+ .padding(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.posOutline, lineWidth: 1)
+ )
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(currency)
+ .font(.caption)
+ .foregroundColor(.posOnSurfaceVariant)
+ TextField("0.00", text: $priceString)
+ .keyboardType(.decimalPad)
+ .padding(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(priceError != nil ? Color.posError : Color.posOutline, lineWidth: 1)
+ )
+ .onChange(of: priceString) { _ in
+ if priceString.isEmpty {
+ priceError = nil
+ } else if parsedPrice == nil {
+ priceError = S.refundErrorInvalidAmount
+ } else {
+ priceError = nil
+ }
+ }
+ if let priceError {
+ Text(priceError)
+ .font(.caption)
+ .foregroundColor(.posError)
+ }
+ }
+
+ Spacer()
+ }
+ .padding(20)
+ .navigationTitle(S.orderCustom)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(S.refundAbort) { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(S.orderCustomAdd) {
+ guard !name.isEmpty,
+ let price = parsedPrice,
+ !price.isZero else { return }
+ let product = ConfigProduct(
+ description: name,
+ price: price,
+ categories: [Int.min],
+ quantity: 1
+ )
+ onAdd(product)
+ dismiss()
+ }
+ .disabled(name.isEmpty || priceString.isEmpty || parsedPrice == nil || parsedPrice?.isZero == true)
+ }
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Views/PaymentView.swift b/TalerPOS/Views/PaymentView.swift
@@ -0,0 +1,286 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+// MARK: - ProcessPaymentView
+
+struct ProcessPaymentView: View {
+ @EnvironmentObject var paymentManager: PaymentManager
+ @EnvironmentObject var orderManager: OrderManager
+ @EnvironmentObject var configManager: ConfigManager
+ let onNavigate: (PosDestination) -> Void
+
+ @State private var showForceDeleteDialog = false
+ @State private var errorTitle: String = ""
+ @State private var errorDetail: String = ""
+ @State private var showErrorAlert = false
+ @Environment(\.dismiss) private var dismiss
+
+ private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad }
+
+ var body: some View {
+ paymentBody
+ .navigationTitle(S.paymentProcessLabel)
+ .navigationBarTitleDisplayMode(.inline)
+ .onReceive(paymentManager.$payment) { payment in
+ guard let payment = payment else { return }
+ handlePaymentChange(payment)
+ }
+ .onReceive(paymentManager.$deleteNeedsForce) { needsForce in
+ handleDeleteNeedsForce(needsForce)
+ }
+ .alert(S.commonError, isPresented: $showErrorAlert) {
+ Button(S.commonOk) { dismiss() }
+ } message: {
+ Text("\(errorTitle)\n\(errorDetail)")
+ }
+ .alert(S.forceDeleteTitle, isPresented: $showForceDeleteDialog) {
+ Button(S.forceDeleteConfirm, role: .destructive) {
+ paymentManager.forceDeleteOrder()
+ dismiss()
+ }
+ Button(S.paymentCancel, role: .cancel) {
+ paymentManager.clearDeleteNeedsForce()
+ }
+ } message: {
+ Text(S.forceDeleteMessage)
+ }
+ }
+
+ @ViewBuilder
+ private var paymentBody: some View {
+ if let payment = paymentManager.payment {
+ GeometryReader { geo in
+ let leftWidth = geo.size.width * (isTablet ? 0.54 : 0.5)
+ let rightWidth = geo.size.width * (isTablet ? 0.46 : 0.5)
+ let qrSize = min(geo.size.width * (isTablet ? 0.45 : 0.4), geo.size.height * 0.7)
+ HStack(spacing: 0) {
+ PaymentQRColumn(payment: payment, width: leftWidth, qrSize: qrSize, onShare: shareText)
+ Divider()
+ PaymentInfoColumn(payment: payment, displayDigits: configManager.currencySpec?.displayDigits ?? 2, width: rightWidth, onCancel: onCancelTapped)
+ }
+ }
+ } else {
+ ProgressView(S.configFetching)
+ }
+ }
+
+ private func handlePaymentChange(_ payment: Payment) {
+ if payment.paid { return }
+ if let error = payment.error {
+ let display = getPaymentErrorDisplay(payment: payment, error: error)
+ errorTitle = display.0
+ errorDetail = display.1
+ showErrorAlert = true
+ }
+ }
+
+ private func handleDeleteNeedsForce(_ needsForce: Bool?) {
+ if needsForce == true {
+ showForceDeleteDialog = true
+ } else if needsForce == false {
+ paymentManager.clearDeleteNeedsForce()
+ dismiss()
+ }
+ }
+
+ private func onCancelTapped() {
+ if let payment = paymentManager.payment, payment.claimed {
+ paymentManager.tryDeleteOrder()
+ } else {
+ paymentManager.cancelPayment()
+ dismiss()
+ }
+ }
+
+ private func getPaymentErrorDisplay(payment: Payment, error: String) -> (String, String) {
+ if payment.orderId != nil {
+ return (S.errorPayment, error)
+ }
+ let normalized = error.lowercased()
+ if normalized.contains("inventory") || normalized.contains("stock") ||
+ normalized.contains("insufficient") || normalized.contains("sold out") ||
+ normalized.contains("out of stock") {
+ return (S.errorInventoryUnavailable, error)
+ }
+ return (S.errorOrderCreation, error)
+ }
+
+ private func shareText(_ text: String) {
+ guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let root = windowScene.windows.first?.rootViewController else { return }
+ let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
+ if let popover = activityVC.popoverPresentationController {
+ popover.sourceView = root.view
+ popover.sourceRect = CGRect(x: root.view.bounds.midX, y: root.view.bounds.midY, width: 0, height: 0)
+ }
+ root.present(activityVC, animated: true)
+ }
+}
+
+// MARK: - Payment Sub-views
+
+private struct PaymentQRColumn: View {
+ let payment: Payment
+ let width: CGFloat
+ let qrSize: CGFloat
+ let onShare: (String) -> Void
+
+ var body: some View {
+ VStack {
+ Spacer()
+ qrContent
+ Spacer()
+ shareButtons
+ }
+ .frame(width: width)
+ }
+
+ @ViewBuilder
+ private var qrContent: some View {
+ if !payment.claimed {
+ PosQRView(text: payment.talerPayUri, size: qrSize)
+ }
+ }
+
+ @ViewBuilder
+ private var shareButtons: some View {
+ if !payment.claimed, let payUri = payment.talerPayUri {
+ HStack(spacing: 12) {
+ Button(S.paymentShareUri) { onShare(payUri) }
+ .buttonStyle(.bordered)
+ Button(S.paymentCopyUri) { UIPasteboard.general.string = payUri }
+ .buttonStyle(.bordered)
+ }
+ .padding(.bottom, 12)
+ }
+ }
+}
+
+private struct PaymentInfoColumn: View {
+ let payment: Payment
+ var displayDigits: Int = 2
+ let width: CGFloat
+ let onCancel: () -> Void
+
+ private var introText: String {
+ if payment.claimed {
+ return S.paymentClaimed
+ } else if payment.talerPayUri != nil {
+ return S.paymentIntro
+ } else {
+ return S.paymentFetchingLink
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Text(introText)
+ .font(.title3)
+ .multilineTextAlignment(.center)
+ Text(payment.order.total.readableAmount(numDigits: displayDigits))
+ .font(.title)
+ .fontWeight(.bold)
+ orderIdView
+ Spacer()
+ cancelButton
+ }
+ .padding()
+ .frame(width: width)
+ }
+
+ @ViewBuilder
+ private var orderIdView: some View {
+ if let orderId = payment.orderId {
+ Text(S.paymentOrderId(orderId))
+ .font(.body)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var cancelButton: some View {
+ Button(action: onCancel) {
+ Text(S.paymentCancel)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.posError)
+ .foregroundColor(.posOnError)
+ }
+}
+
+// MARK: - PaymentSuccessView
+
+struct PaymentSuccessView: View {
+ let onNavigate: (PosDestination) -> Void
+ @EnvironmentObject var paymentManager: PaymentManager
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ GeometryReader { geo in
+ let iconSize: CGFloat = min(geo.size.height * 0.22, 164.0)
+ let buttonWidth: CGFloat = geo.size.width < 600 ? geo.size.width * 0.78 : geo.size.width * 0.6
+
+ VStack(spacing: 0) {
+ Spacer()
+ successHero(iconSize: iconSize, height: geo.size.height)
+ Spacer()
+ continueButton(width: buttonWidth, height: geo.size.height)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 24)
+ }
+ .navigationTitle(S.paymentReceived)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarBackButtonHidden(true)
+ }
+
+ private func successHero(iconSize: CGFloat, height: CGFloat) -> some View {
+ VStack(spacing: max(height * 0.035, 12)) {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: iconSize, height: iconSize)
+ .foregroundColor(.posPrimary)
+
+ Text(S.paymentReceived)
+ .font(height < 560 ? .title2 : height < 720 ? .title : .largeTitle)
+ .fontWeight(.semibold)
+ .foregroundColor(.posPrimary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ private func continueButton(width: CGFloat, height: CGFloat) -> some View {
+ Button {
+ let destination: PosDestination = paymentManager.payment?.origin == .amountEntry ? .amountEntry : .order
+ paymentManager.reset()
+ onNavigate(destination)
+ } label: {
+ Text(S.paymentBackButton)
+ .fontWeight(.semibold)
+ .frame(width: width)
+ .padding(.vertical, max(height * 0.02, 14))
+ }
+ .buttonStyle(.borderedProminent)
+ .padding(.bottom, max(height * 0.045, 16))
+ }
+}
+
diff --git a/TalerPOS/Views/PosQRView.swift b/TalerPOS/Views/PosQRView.swift
@@ -0,0 +1,148 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+import CoreImage.CIFilterBuiltins
+
+
+private let qrCornerRadius: CGFloat = 0.08
+private let qrStripeWidth: CGFloat = 0.025
+private let qrDataFraction: CGFloat = 0.88
+private let qrLogoFraction: CGFloat = 0.30 * qrDataFraction
+
+struct PosQRView: View {
+ let text: String?
+ let size: CGFloat
+
+ @State private var qrImage: UIImage?
+ @State private var showQR = false
+
+ var body: some View {
+ let cornerRadius = size * qrCornerRadius
+ let qrSize = size * qrDataFraction
+ let logoWidth = size * qrLogoFraction
+
+ ZStack {
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(.white)
+ .frame(width: size, height: size)
+
+ SpinningBorder(size: size)
+
+ if showQR, let qrImage {
+ Image(uiImage: qrImage)
+ .interpolation(.none)
+ .resizable()
+ .scaledToFit()
+ .frame(width: qrSize, height: qrSize)
+ .transition(.opacity)
+
+ Image("taler-logo-qr")
+ .resizable()
+ .scaledToFit()
+ .frame(width: logoWidth)
+ .transition(.opacity)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ .animation(.easeIn(duration: 0.3), value: showQR)
+ .onAppear {
+ if let text {
+ generateQRAsync(from: text)
+ }
+ }
+ .onChange(of: text) { newText in
+ if let newText {
+ generateQRAsync(from: newText)
+ } else {
+ showQR = false
+ qrImage = nil
+ }
+ }
+ }
+
+ private func generateQRAsync(from string: String) {
+ let targetSize = size * qrDataFraction
+ Task.detached(priority: .userInitiated) {
+ let context = CIContext()
+ let filter = CIFilter.qrCodeGenerator()
+ filter.message = Data(string.utf8)
+ filter.correctionLevel = "H"
+ guard let ciImage = filter.outputImage else { return }
+
+ let modules = ciImage.extent.width
+ let scale = max(1, Int(ceil(Double(targetSize) / modules)))
+ let transformed = ciImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
+
+ guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return }
+ let image = UIImage(cgImage: cgImage)
+
+ await MainActor.run {
+ qrImage = image
+ showQR = true
+ }
+ }
+ }
+}
+
+private struct SpinningBorder: View {
+ let size: CGFloat
+ @State private var rotation: Double = 0
+
+ private let stripeColor = Color(red: 0.188, green: 0.278, blue: 0.639)
+
+ private var stripeSoft: Color {
+ Color(
+ red: 1.0 * 0.45 + 0.188 * 0.55,
+ green: 1.0 * 0.45 + 0.278 * 0.55,
+ blue: 1.0 * 0.45 + 0.639 * 0.55
+ )
+ }
+
+ var body: some View {
+ let cornerRadius = size * qrCornerRadius
+ let stripeWidth = size * qrStripeWidth
+
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .strokeBorder(
+ AngularGradient(
+ gradient: Gradient(stops: [
+ .init(color: .white, location: 0.00),
+ .init(color: .white, location: 0.05),
+ .init(color: stripeSoft, location: 0.09),
+ .init(color: stripeColor, location: 0.12),
+ .init(color: stripeSoft, location: 0.16),
+ .init(color: .white, location: 0.21),
+ .init(color: .white, location: 0.50),
+ .init(color: stripeSoft, location: 0.55),
+ .init(color: stripeColor, location: 0.59),
+ .init(color: stripeSoft, location: 0.62),
+ .init(color: .white, location: 0.66),
+ .init(color: .white, location: 1.00),
+ ]),
+ center: .center,
+ angle: .degrees(rotation)
+ ),
+ lineWidth: stripeWidth
+ )
+ .frame(width: size, height: size)
+ .onAppear {
+ withAnimation(.linear(duration: 5.25).repeatForever(autoreverses: false)) {
+ rotation = 360
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Views/QRScannerView.swift b/TalerPOS/Views/QRScannerView.swift
@@ -0,0 +1,153 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+import AVFoundation
+
+struct QRScannerView: UIViewControllerRepresentable {
+ let onResult: (Result<String, Error>) -> Void
+
+ func makeUIViewController(context: Context) -> QRScannerViewController {
+ let vc = QRScannerViewController()
+ vc.onResult = onResult
+ return vc
+ }
+
+ func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {}
+}
+
+final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
+ var onResult: ((Result<String, Error>) -> Void)?
+ private let captureSession = AVCaptureSession()
+ private var previewLayer: AVCaptureVideoPreviewLayer?
+ private var hasReported = false
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .black
+
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ setupCamera()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ DispatchQueue.main.async {
+ if granted {
+ self?.setupCamera()
+ } else {
+ self?.reportError()
+ }
+ }
+ }
+ default:
+ reportError()
+ }
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ previewLayer?.frame = view.bounds
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ updatePreviewRotation()
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(orientationDidChange),
+ name: UIDevice.orientationDidChangeNotification,
+ object: nil
+ )
+ if !captureSession.isRunning {
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ self?.captureSession.startRunning()
+ }
+ }
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
+ if captureSession.isRunning {
+ captureSession.stopRunning()
+ }
+ }
+
+ @objc private func orientationDidChange() {
+ updatePreviewRotation()
+ }
+
+ private func setupCamera() {
+ guard let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device) else {
+ reportError()
+ return
+ }
+
+ if captureSession.canAddInput(input) {
+ captureSession.addInput(input)
+ }
+
+ let output = AVCaptureMetadataOutput()
+ if captureSession.canAddOutput(output) {
+ captureSession.addOutput(output)
+ output.setMetadataObjectsDelegate(self, queue: .main)
+ output.metadataObjectTypes = [.qr]
+ }
+
+ let layer = AVCaptureVideoPreviewLayer(session: captureSession)
+ layer.videoGravity = .resizeAspectFill
+ layer.frame = view.bounds
+ view.layer.addSublayer(layer)
+ previewLayer = layer
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ self?.captureSession.startRunning()
+ DispatchQueue.main.async {
+ self?.updatePreviewRotation()
+ }
+ }
+ }
+
+ private func updatePreviewRotation() {
+ guard let connection = previewLayer?.connection,
+ connection.isVideoOrientationSupported else { return }
+ let orientation: AVCaptureVideoOrientation
+ switch view.window?.windowScene?.interfaceOrientation {
+ case .portrait: orientation = .portrait
+ case .landscapeLeft: orientation = .landscapeLeft
+ case .landscapeRight: orientation = .landscapeRight
+ case .portraitUpsideDown: orientation = .portraitUpsideDown
+ default: orientation = .landscapeRight
+ }
+ connection.videoOrientation = orientation
+ }
+
+ private func reportError() {
+ guard !hasReported else { return }
+ hasReported = true
+ onResult?(.failure(NSError(domain: "QRScanner", code: 1)))
+ }
+
+ func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
+ guard !hasReported,
+ let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
+ let value = object.stringValue else { return }
+ hasReported = true
+ captureSession.stopRunning()
+ onResult?(.success(value))
+ }
+}
diff --git a/TalerPOS/Views/RefundView.swift b/TalerPOS/Views/RefundView.swift
@@ -0,0 +1,219 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+// MARK: - RefundFormView
+
+struct RefundFormView: View {
+ @EnvironmentObject var refundManager: RefundManager
+ @EnvironmentObject var configManager: ConfigManager
+ @Environment(\.dismiss) private var dismiss
+ let item: OrderHistoryEntry
+
+ @State private var amountText: String = ""
+ @State private var reason: String = ""
+ @State private var errorText: String? = nil
+ @State private var showErrorAlert: (String, String)? = nil
+ @FocusState private var amountFocused: Bool
+ @FocusState private var reasonFocused: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(item.summary)
+ .font(.body)
+
+ TextField(S.refundAmount, text: $amountText)
+ .keyboardType(.decimalPad)
+ .textFieldStyle(.roundedBorder)
+ .focused($amountFocused)
+ .onSubmit { reasonFocused = true }
+ .onChange(of: amountText) { _ in errorText = nil }
+
+ if let errorText = errorText {
+ Text(errorText)
+ .font(.caption)
+ .foregroundColor(.posError)
+ }
+
+ TextField(S.refundReason, text: $reason)
+ .textFieldStyle(.roundedBorder)
+ .focused($reasonFocused)
+ .onSubmit { submitRefund() }
+
+ Button(action: submitRefund) {
+ Text(S.refundConfirm)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button(action: {
+ refundManager.abortRefund()
+ dismiss()
+ }) {
+ Text(S.paymentCancel)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+
+ Spacer()
+ }
+ .padding(20)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ amountFocused = false
+ reasonFocused = false
+ }
+ .navigationTitle(S.historyRefund)
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ amountText = item.amount.amountStr(numDigits: configManager.currencySpec?.inputDigits ?? 2)
+ amountFocused = true
+ }
+ .onChange(of: refundManager.refundResult) { result in
+ guard let result = result else { return }
+ switch result {
+ case .error(let msg):
+ showErrorAlert = (S.historyRefund, msg)
+ case .pastDeadline:
+ showErrorAlert = (S.historyRefund, S.refundErrorDeadline)
+ case .alreadyRefunded:
+ showErrorAlert = (S.historyRefund, S.refundErrorAlreadyRefunded)
+ case .success:
+ break
+ }
+ }
+ .alert(S.commonError, isPresented: .init(
+ get: { showErrorAlert != nil },
+ set: { if !$0 { showErrorAlert = nil } }
+ )) {
+ Button(S.commonOk) { showErrorAlert = nil }
+ } message: {
+ if let err = showErrorAlert {
+ Text("\(err.0): \(err.1)")
+ }
+ }
+ }
+
+ private func submitRefund() {
+ amountFocused = false
+ reasonFocused = false
+
+ let maxAmount = item.amount
+ let normalizedText = amountText.trimmingCharacters(in: .whitespaces)
+
+ let inputAmount: Amount
+ if normalizedText.isEmpty {
+ inputAmount = maxAmount
+ } else {
+ guard let parsed = Amount.fromString(item.amount.currency, normalizedText) else {
+ errorText = S.refundErrorInvalidAmount
+ return
+ }
+ inputAmount = parsed
+ }
+
+ if inputAmount > maxAmount {
+ errorText = S.refundErrorMaxAmount(maxAmount.amountStr(numDigits: configManager.currencySpec?.inputDigits ?? 2))
+ return
+ }
+ if inputAmount.isZero {
+ errorText = S.refundErrorZero
+ return
+ }
+
+ refundManager.refund(item: item, amount: inputAmount, reason: reason)
+ }
+}
+
+// MARK: - RefundUriView
+
+struct RefundUriView: View {
+ @EnvironmentObject var refundManager: RefundManager
+ @EnvironmentObject var configManager: ConfigManager
+ @Environment(\.dismiss) private var dismiss
+
+ private var isTablet: Bool { UIDevice.current.userInterfaceIdiom == .pad }
+
+ var body: some View {
+ Group {
+ if case .success(let refundUri, let item, let amount, let reason) = refundManager.refundResult {
+ GeometryReader { geo in
+ HStack(spacing: 0) {
+ VStack {
+ Spacer()
+ let qrSize = min(geo.size.width * (isTablet ? 0.45 : 0.4), geo.size.height * 0.7)
+ PosQRView(text: refundUri, size: qrSize)
+ Spacer()
+ }
+ .frame(width: geo.size.width * (isTablet ? 0.54 : 0.5))
+
+ Divider()
+
+ VStack(spacing: 16) {
+ Spacer()
+
+ Text(S.refundIntro)
+ .font(.title3)
+ .multilineTextAlignment(.center)
+
+ Text(amount.readableAmount(numDigits: configManager.currencySpec?.displayDigits ?? 2))
+ .font(.title)
+ .fontWeight(.bold)
+
+ Text(S.paymentOrderId(item.orderId) + "\n" + S.refundReason + ": " + reason)
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+
+ Spacer()
+
+ Button(action: {
+ refundManager.abortRefund()
+ dismiss()
+ }) {
+ Text(S.refundAbort)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.posError)
+ .foregroundColor(.posOnError)
+ }
+ .padding()
+ .frame(width: geo.size.width * (isTablet ? 0.46 : 0.5))
+ }
+ }
+ } else {
+ VStack {
+ Spacer()
+ Text(S.refundStateMissing)
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ .onAppear { dismiss() }
+ }
+ }
+ .navigationTitle(S.historyRefund)
+ .navigationBarTitleDisplayMode(.inline)
+ .onChange(of: refundManager.refundReceived) { received in
+ if received {
+ refundManager.completeRefund()
+ }
+ }
+ }
+}
diff --git a/TalerPOS/Views/SettingsView.swift b/TalerPOS/Views/SettingsView.swift
@@ -0,0 +1,116 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import SwiftUI
+
+
+private struct LanguageOption: Identifiable {
+ let tag: String
+ let label: String
+ var id: String { tag }
+}
+
+private let supportedLanguages: [LanguageOption] = [
+ LanguageOption(tag: "", label: S.settingsLanguageSystemDefault),
+ LanguageOption(tag: "en", label: "English"),
+ LanguageOption(tag: "de", label: "Deutsch"),
+ LanguageOption(tag: "es", label: "Español"),
+ LanguageOption(tag: "fi", label: "Suomi"),
+ LanguageOption(tag: "fr", label: "Français"),
+ LanguageOption(tag: "it", label: "Italiano"),
+ LanguageOption(tag: "ru", label: "Русский"),
+ LanguageOption(tag: "sv", label: "Svenska"),
+ LanguageOption(tag: "tr", label: "Türkçe"),
+ LanguageOption(tag: "uk", label: "Українська"),
+ LanguageOption(tag: "he", label: "עברית"),
+]
+
+struct SettingsView: View {
+ @EnvironmentObject var configManager: ConfigManager
+ @ObservedObject private var languageManager = LanguageManager.shared
+ let onNavigate: (PosDestination) -> Void
+ var openDrawer: (() -> Void)?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ PosTopBar(title: S.menuSettings, onMenuTap: { openDrawer?() })
+ List {
+ Section {
+ Picker(S.settingsInitialOrderLabel, selection: Binding(
+ get: { configManager.initialOrderScreen },
+ set: { configManager.setInitialOrderScreen($0) }
+ )) {
+ Text(S.menuAmountEntry).tag(InitialOrderScreen.amountEntry)
+ Text(S.menuOrder).tag(InitialOrderScreen.inventory)
+ }
+
+ Picker(S.settingsLanguageLabel, selection: Binding(
+ get: { languageManager.selectedLanguage },
+ set: { languageManager.setLanguage($0) }
+ )) {
+ ForEach(supportedLanguages) { lang in
+ Text(lang.label).tag(lang.tag)
+ }
+ }
+ } header: {
+ Text(S.settingsAppTitle)
+ }
+
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(S.settingsInstanceDescription)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+
+ Button {
+ onNavigate(.config)
+ } label: {
+ HStack {
+ Image(systemName: "gearshape.2")
+ Text(S.settingsInstanceButton)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(Color.posPrimaryContainer)
+ .foregroundColor(.posOnPrimaryContainer)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ configManager.logout()
+ } label: {
+ Text(S.settingsLogoutButton)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(Color.posErrorContainer)
+ .foregroundColor(.posOnErrorContainer)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ }
+ } header: {
+ Text(S.settingsInstanceTitle)
+ }
+
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.posSurface)
+ }
+ .background(Color.posSurface)
+ .navigationBarHidden(true)
+ }
+}
diff --git a/TalerPOS/scripts/capture-screenshots.sh b/TalerPOS/scripts/capture-screenshots.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+BASE_DIR="${1:-$REPO_ROOT/TalerPOS/build/screenshots}"
+
+# Accept simulators as remaining args, or auto-detect
+shift || true
+if [[ $# -gt 0 ]]; then
+ SIMULATORS=("$@")
+else
+ echo "Auto-detecting available simulators..."
+ # Pick the latest-OS iPhone and iPad simulators
+ SIMULATORS=()
+ IPHONE=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \
+ | grep 'platform:iOS Simulator' | grep 'name:iPhone' \
+ | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1)
+ IPAD=$(xcodebuild -project "$REPO_ROOT/TalerPOS.xcodeproj" -scheme TalerPOS -showdestinations 2>/dev/null \
+ | grep 'platform:iOS Simulator' | grep 'name:iPad' \
+ | sed 's/.*name:\([^,}]*\).*/\1/' | sed 's/ *$//' | sort -u | tail -1)
+ [[ -n "$IPHONE" ]] && SIMULATORS+=("$IPHONE")
+ [[ -n "$IPAD" ]] && SIMULATORS+=("$IPAD")
+
+ if [[ ${#SIMULATORS[@]} -eq 0 ]]; then
+ echo "No simulators found. Pass device names manually." >&2
+ exit 1
+ fi
+ echo " Found: ${SIMULATORS[*]}"
+ echo ""
+fi
+
+# Read version from the Xcode project
+VERSION=$(grep 'MARKETING_VERSION' "$REPO_ROOT/TalerPOS.xcodeproj/project.pbxproj" \
+ | head -1 | sed 's/.*= *"\{0,1\}\([^";]*\)"\{0,1\}.*/\1/' | tr -d ' ')
+VERSION="${VERSION:-unknown}"
+
+# Ensure scheme includes TalerPOSUITests (Xcode sometimes strips it)
+SCHEME_FILE="$REPO_ROOT/TalerPOS.xcodeproj/xcshareddata/xcschemes/TalerPOS.xcscheme"
+if ! grep -q 'BlueprintName = "TalerPOSUITests"' "$SCHEME_FILE" 2>/dev/null; then
+ echo "Injecting TalerPOSUITests into scheme..."
+ UI_TEST_REF=' <TestableReference\
+ skipped = "NO">\
+ <BuildableReference\
+ BuildableIdentifier = "primary"\
+ BlueprintIdentifier = "E5000001"\
+ BuildableName = "TalerPOSUITests.xctest"\
+ BlueprintName = "TalerPOSUITests"\
+ ReferencedContainer = "container:TalerPOS.xcodeproj">\
+ <\/BuildableReference>\
+ <\/TestableReference>'
+ sed -i '' "/<\/Testables>/i\\
+$UI_TEST_REF
+" "$SCHEME_FILE"
+fi
+
+echo "=== Capturing screenshots for version $VERSION ==="
+echo ""
+
+for SIMULATOR in "${SIMULATORS[@]}"; do
+ echo "--- Device: $SIMULATOR ---"
+
+ # Sanitize device name for folder (spaces -> underscores, lowercase)
+ DEVICE_FOLDER=$(echo "$SIMULATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]')
+ DEVICE_DIR="$BASE_DIR/$VERSION/$DEVICE_FOLDER"
+ mkdir -p "$DEVICE_DIR"
+
+ RESULT_BUNDLE="$DEVICE_DIR/ScreenshotTests.xcresult"
+ rm -rf "$RESULT_BUNDLE"
+
+ TMPDIR_EXPORT=$(mktemp -d)
+
+ xcodebuild test \
+ -project "$REPO_ROOT/TalerPOS.xcodeproj" \
+ -scheme TalerPOS \
+ -destination "platform=iOS Simulator,name=$SIMULATOR" \
+ -only-testing:TalerPOSUITests/ScreenshotTests \
+ -resultBundlePath "$RESULT_BUNDLE" \
+ | grep -E '(Test Case|Test Suite|error:|BUILD|Failing)' || true
+
+ echo ""
+
+ # Export attachments + manifest
+ xcrun xcresulttool export attachments \
+ --path "$RESULT_BUNDLE" \
+ --output-path "$TMPDIR_EXPORT"
+
+ # Use manifest.json to rename files by scenario name
+ if [[ -f "$TMPDIR_EXPORT/manifest.json" ]]; then
+ python3 -c "
+import json, shutil, os
+manifest = json.load(open('$TMPDIR_EXPORT/manifest.json'))
+for entry in manifest:
+ for att in entry.get('attachments', []):
+ exported = att.get('exportedFileName', '')
+ suggested = att.get('suggestedHumanReadableName', '')
+ scenario = suggested.split('_0_')[0] if '_0_' in suggested else suggested.rsplit('.', 1)[0]
+ src = os.path.join('$TMPDIR_EXPORT', exported)
+ dst = os.path.join('$DEVICE_DIR', scenario + '.png')
+ if os.path.exists(src):
+ shutil.move(src, dst)
+ print(f' {scenario}.png')
+"
+ fi
+
+ rm -rf "$TMPDIR_EXPORT"
+ rm -rf "$RESULT_BUNDLE"
+
+ echo ""
+done
+
+echo "=== Done ==="
+echo "Screenshots at: $BASE_DIR/$VERSION/"
+find "$BASE_DIR/$VERSION" -name "*.png" | sort
diff --git a/TalerPOSTests/ConfigProductTests.swift b/TalerPOSTests/ConfigProductTests.swift
@@ -0,0 +1,105 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import XCTest
+@testable import TalerPOS
+
+final class ConfigProductTests: XCTestCase {
+
+ func testDisplayDescriptionIsHiddenWhenSameAsProductName() {
+ let product = ConfigProduct(
+ productName: "Coffee",
+ description: "Coffee",
+ price: Amount(currency: "KUDOS", value: 2, fraction: 0),
+ categories: [1]
+ )
+
+ XCTAssertEqual("Coffee", product.displayName)
+ XCTAssertNil(product.displayDescription)
+ }
+
+ func testDisplayPriceFormatsAmount() {
+ let product = ConfigProduct(
+ description: "Coffee",
+ price: Amount(currency: "KUDOS", value: 2, fraction: 50000000),
+ categories: [1]
+ )
+
+ XCTAssertEqual("2.50 KUDOS", product.price.readableAmount)
+ }
+
+ func testStockLimitSubtractsSoldAndLostFromTotalStock() {
+ let product = ConfigProduct(
+ description: "finite_quantity",
+ price: Amount(currency: "KUDOS", value: 10, fraction: 0),
+ categories: [],
+ totalStock: 10,
+ unitTotalStock: "10",
+ totalSold: 0,
+ totalLost: 2
+ )
+
+ XCTAssertEqual(8, product.stockLimit)
+ }
+
+ func testStockLimitSubtractsUnitSoldAndLost() {
+ let product = ConfigProduct(
+ description: "test",
+ price: Amount(currency: "KUDOS", value: 5, fraction: 0),
+ categories: [],
+ unitTotalStock: "10",
+ unitTotalSold: "2",
+ unitTotalLost: "3"
+ )
+
+ XCTAssertEqual(5, product.stockLimit)
+ }
+
+ func testStockLimitWithNoSoldOrLostReturnsTotalStock() {
+ let product = ConfigProduct(
+ description: "test",
+ price: Amount(currency: "KUDOS", value: 5, fraction: 0),
+ categories: [],
+ totalStock: 10
+ )
+
+ XCTAssertEqual(10, product.stockLimit)
+ }
+
+ func testStockLimitReturnsNilForUnlimitedStock() {
+ let product = ConfigProduct(
+ description: "test",
+ price: Amount(currency: "KUDOS", value: 5, fraction: 0),
+ categories: [],
+ totalStock: -1
+ )
+
+ XCTAssertNil(product.stockLimit)
+ }
+
+ func testStockLimitClampsToZeroWhenSoldAndLostExceedStock() {
+ let product = ConfigProduct(
+ description: "test",
+ price: Amount(currency: "KUDOS", value: 5, fraction: 0),
+ categories: [],
+ totalStock: 5,
+ totalSold: 3,
+ totalLost: 5
+ )
+
+ XCTAssertEqual(0, product.stockLimit)
+ }
+}
diff --git a/TalerPOSTests/OrderManagerTests.swift b/TalerPOSTests/OrderManagerTests.swift
@@ -0,0 +1,155 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import XCTest
+@testable import TalerPOS
+
+@MainActor
+final class OrderManagerTests: XCTestCase {
+
+ private var orderManager: OrderManager!
+
+ private let posConfig = PosConfig(
+ categories: [
+ Category(id: 1, name: "one"),
+ Category(id: 2, name: "two"),
+ ],
+ products: [
+ ConfigProduct(
+ description: "foo",
+ price: Amount(currency: "KUDOS", value: 1, fraction: 0),
+ categories: [1]
+ ),
+ ConfigProduct(
+ description: "bar",
+ price: Amount(currency: "KUDOS", value: 1, fraction: 50000000),
+ categories: [2]
+ ),
+ ]
+ )
+
+ override func setUp() {
+ super.setUp()
+ orderManager = OrderManager()
+ }
+
+ func testConfigErrorOnMissingCategories() {
+ let config = PosConfig(categories: [], products: posConfig.products)
+ let result = orderManager.onConfigurationReceived(
+ posConfig: config, currency: "KUDOS", currencySpec: nil
+ )
+ XCTAssertNotNil(result)
+ }
+
+ func testCurrencyMismatchProductIsAcceptedButUnavailable() {
+ let products = [
+ ConfigProduct(
+ description: "foo",
+ price: Amount(currency: "WRONGCUR", value: 1, fraction: 0),
+ categories: [1]
+ )
+ ]
+ let config = PosConfig(categories: posConfig.categories, products: products)
+ let result = orderManager.onConfigurationReceived(
+ posConfig: config, currency: "KUDOS", currencySpec: nil
+ )
+
+ XCTAssertNil(result)
+ let product = orderManager.products.first { $0.description == "foo" }
+ XCTAssertNotNil(product)
+ XCTAssertEqual("WRONGCUR", product?.price.currency)
+ XCTAssertTrue(product?.currencyMismatch == true)
+ XCTAssertFalse(product?.availableToSell == true)
+ }
+
+ func testValidConfigGetsAccepted() {
+ let result = orderManager.onConfigurationReceived(
+ posConfig: posConfig, currency: "KUDOS", currencySpec: nil
+ )
+ XCTAssertNil(result)
+ }
+
+ func testAllObjectsIsSelectedByDefaultAndShownFirst() {
+ let result = orderManager.onConfigurationReceived(
+ posConfig: posConfig, currency: "KUDOS", currencySpec: nil
+ )
+ XCTAssertNil(result)
+
+ let categories = orderManager.categories
+ let products = orderManager.products
+
+ XCTAssertFalse(categories.isEmpty)
+ XCTAssertFalse(products.isEmpty)
+ XCTAssertTrue(categories[0].selected)
+ XCTAssertFalse(categories[1].selected)
+ XCTAssertEqual(posConfig.products.count, products.count)
+ }
+
+ func testUncategorizedIsAppendedAtTheEndWhenNeeded() {
+ let uncategorizedProduct = ConfigProduct(
+ description: "baz",
+ price: Amount(currency: "KUDOS", value: 2, fraction: 0),
+ categories: []
+ )
+ let config = PosConfig(
+ categories: posConfig.categories,
+ products: posConfig.products + [uncategorizedProduct]
+ )
+
+ let result = orderManager.onConfigurationReceived(
+ posConfig: config, currency: "KUDOS", currencySpec: nil
+ )
+ XCTAssertNil(result)
+
+ let categories = orderManager.categories
+ let uncategorized = categories.last!
+ XCTAssertEqual(uncategorized.id, -2)
+
+ orderManager.selectCategory(uncategorized)
+ let uncatProducts = orderManager.products
+ XCTAssertEqual(1, uncatProducts.count)
+ XCTAssertEqual("baz", uncatProducts[0].description)
+ }
+
+ func testLegacyDefaultCategoryIsHiddenAndMappedToUncategorized() {
+ let defaultCategory = Category(id: 3, name: "Default")
+ let defaultProduct = ConfigProduct(
+ description: "legacy",
+ price: Amount(currency: "KUDOS", value: 3, fraction: 0),
+ categories: [3]
+ )
+ let config = PosConfig(
+ categories: posConfig.categories + [defaultCategory],
+ products: posConfig.products + [defaultProduct]
+ )
+
+ let result = orderManager.onConfigurationReceived(
+ posConfig: config, currency: "KUDOS", currencySpec: nil
+ )
+ XCTAssertNil(result)
+
+ let categories = orderManager.categories
+ XCTAssertFalse(categories.contains(where: { $0.name == "Default" }))
+
+ let uncategorized = categories.last!
+ XCTAssertEqual(uncategorized.id, -2)
+
+ orderManager.selectCategory(uncategorized)
+ let uncatProducts = orderManager.products
+ XCTAssertEqual(1, uncatProducts.count)
+ XCTAssertEqual("legacy", uncatProducts[0].description)
+ }
+}
diff --git a/TalerPOSUITests/ScreenshotTests.swift b/TalerPOSUITests/ScreenshotTests.swift
@@ -0,0 +1,50 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 Bohdan, Volodymyr Potuzhnyi
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import XCTest
+
+final class ScreenshotTests: XCTestCase {
+
+ private func launchAndCapture(scenario: String) {
+ let app = XCUIApplication()
+ app.launchArguments += ["-SCREENSHOT_SCENARIO", scenario]
+ app.launch()
+
+ sleep(2)
+
+ let screenshot = app.windows.firstMatch.screenshot()
+ let attachment = XCTAttachment(screenshot: screenshot)
+ attachment.name = scenario
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+
+ func testScreenshotAmountEntry() {
+ launchAndCapture(scenario: "amount-entry")
+ }
+
+ func testScreenshotOrder() {
+ launchAndCapture(scenario: "order")
+ }
+
+ func testScreenshotPayment() {
+ launchAndCapture(scenario: "payment")
+ }
+
+ func testScreenshotPaymentSuccess() {
+ launchAndCapture(scenario: "payment-success")
+ }
+}