From 6249eea905b15dbbde33234709384f0f4c642d7f Mon Sep 17 00:00:00 2001 From: vkamn Date: Wed, 1 Apr 2026 14:25:47 +0800 Subject: [PATCH] feat: additional parsing for storekit subscription plans --- client/platforms/ios/StoreKit2Helper.swift | 38 ++++++++++- client/platforms/ios/ios_controller.mm | 9 +++ .../controllers/api/apiConfigsController.cpp | 64 ++++++++++++++++--- .../Pages2/PageSetupWizardApiPremiumInfo.qml | 17 +---- 4 files changed, 102 insertions(+), 26 deletions(-) diff --git a/client/platforms/ios/StoreKit2Helper.swift b/client/platforms/ios/StoreKit2Helper.swift index d8b9a23b0..dc150d22b 100644 --- a/client/platforms/ios/StoreKit2Helper.swift +++ b/client/platforms/ios/StoreKit2Helper.swift @@ -94,20 +94,54 @@ public class StoreKit2Helper: NSObject { product.priceFormatStyle.locale.currencyCode ?? "" } + private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double { + let v = Double(period.value) + switch period.unit { + case .day: + return v / 30.0 + case .week: + return v * 7.0 / 30.0 + case .month: + return v + case .year: + return v * 12.0 + @unknown default: + return v + } + } + public func fetchProducts(identifiers: Set, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) { Task { do { let products = try await Product.products(for: identifiers) let productDicts = products.map { product -> NSDictionary in let currencyCode = storefrontCurrencyCode(for: product) - return [ + var dict: [String: Any] = [ "productId": product.id, "title": product.displayName, "description": product.description, "price": "\(product.price)", "displayPrice": product.displayPrice, - "currencyCode": currencyCode + "currencyCode": currencyCode, + "priceAmount": NSDecimalNumber(decimal: product.price).doubleValue ] + if let sub = product.subscription { + let months = subscriptionBillingMonths(sub.subscriptionPeriod) + dict["subscriptionBillingMonths"] = months + if months > 1e-6 { + let perMonth = product.price / Decimal(months) + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = product.priceFormatStyle.locale + if !currencyCode.isEmpty { + formatter.currencyCode = currencyCode + } + if let perMonthStr = formatter.string(from: NSDecimalNumber(decimal: perMonth)) { + dict["displayPricePerMonth"] = perMonthStr + } + } + } + return dict as NSDictionary } let fetchedIds = Set(products.map { $0.id }) let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) } diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 5f4ba28a1..f4769933a 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -1157,6 +1157,15 @@ void IosController::fetchProducts(const QStringList &productIds, m["displayPrice"] = QString::fromUtf8([p[@"displayPrice"] UTF8String]); } m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); + if (p[@"priceAmount"]) { + m["priceAmount"] = [p[@"priceAmount"] doubleValue]; + } + if (p[@"subscriptionBillingMonths"]) { + m["subscriptionBillingMonths"] = [p[@"subscriptionBillingMonths"] doubleValue]; + } + if (p[@"displayPricePerMonth"]) { + m["displayPricePerMonth"] = QString::fromUtf8([p[@"displayPricePerMonth"] UTF8String]); + } outProducts.push_back(m); } diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 8d15e8582..5dfe2ce90 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -9,12 +9,14 @@ #include "ui/controllers/systemController.h" #include "version.h" #include +#include #include #include #include #include #include #include +#include #include "platforms/ios/ios_controller.h" @@ -47,6 +49,9 @@ namespace constexpr char subscriptionPlans[] = "subscription_plans"; constexpr char storeProductId[] = "store_product_id"; constexpr char priceLabel[] = "price_label"; + constexpr char subtitle[] = "subtitle"; + constexpr char isTrial[] = "is_trial"; + constexpr char minPriceLabel[] = "min_price_label"; constexpr char apiPayload[] = "api_payload"; constexpr char keyPayload[] = "key_payload"; @@ -249,6 +254,13 @@ namespace } #if defined(Q_OS_IOS) || defined(MACOS_NE) + struct StoreKitPlanQuote { + QString displayPrice; + double priceAmount = 0.0; + double subscriptionBillingMonths = 0.0; + QString displayPricePerMonth; + }; + void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data) { QJsonArray services = data.value(configKey::services).toArray(); @@ -299,20 +311,25 @@ namespace }); loop.exec(); - QHash idToDisplayPrice; - idToDisplayPrice.reserve(fetchedProducts.size()); + QHash idToQuote; + idToQuote.reserve(fetchedProducts.size()); for (const QVariantMap &product : fetchedProducts) { const QString id = product.value(QStringLiteral("productId")).toString(); if (id.isEmpty()) { continue; } + StoreKitPlanQuote quote; QString display = product.value(QStringLiteral("displayPrice")).toString(); if (display.isEmpty()) { const QString price = product.value(QStringLiteral("price")).toString(); const QString currencyCode = product.value(QStringLiteral("currencyCode")).toString(); display = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode); } - idToDisplayPrice.insert(id, display); + quote.displayPrice = display; + quote.priceAmount = product.value(QStringLiteral("priceAmount")).toDouble(); + quote.subscriptionBillingMonths = product.value(QStringLiteral("subscriptionBillingMonths")).toDouble(); + quote.displayPricePerMonth = product.value(QStringLiteral("displayPricePerMonth")).toString(); + idToQuote.insert(id, quote); } for (int i = 0; i < services.size(); ++i) { @@ -324,25 +341,56 @@ namespace QJsonArray plans = description.value(configKey::subscriptionPlans).toArray(); QJsonArray mergedPlans; + double minMonthlyAmount = std::numeric_limits::infinity(); + QString minMonthlyDisplay; + for (const QJsonValue &planValue : plans) { if (!planValue.isObject()) { continue; } QJsonObject planObject = planValue.toObject(); + const bool trial = planObject.value(configKey::isTrial).toBool(); const QString storeId = planObject.value(configKey::storeProductId).toString(); + if (storeId.isEmpty()) { continue; } - const auto priceIt = idToDisplayPrice.constFind(storeId); - if (priceIt == idToDisplayPrice.cend()) { + + const auto quoteIt = idToQuote.constFind(storeId); + if (quoteIt == idToQuote.cend()) { continue; } - planObject.insert(configKey::priceLabel, *priceIt); + + const StoreKitPlanQuote "e = *quoteIt; + planObject.insert(configKey::priceLabel, quote.displayPrice); + + const double months = quote.subscriptionBillingMonths; + if (!trial && months > 1.0 + 1e-6 && !quote.displayPricePerMonth.isEmpty()) { + planObject.insert( + configKey::subtitle, + QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle") + .arg(quote.displayPricePerMonth)); + } + + if (!trial && quote.priceAmount > 0.0) { + const double monthsForMin = months > 1e-6 ? months : 1.0; + const double monthly = quote.priceAmount / monthsForMin; + if (monthly < minMonthlyAmount - 1e-9) { + minMonthlyAmount = monthly; + minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice; + } + } + mergedPlans.append(planObject); } - plans = mergedPlans; - description.insert(configKey::subscriptionPlans, plans); + description.insert(configKey::subscriptionPlans, mergedPlans); + if (minMonthlyAmount < std::numeric_limits::infinity() && !minMonthlyDisplay.isEmpty()) { + description.insert(configKey::minPriceLabel, + QCoreApplication::translate("ApiConfigsController", "from %1 per month", + "IAP: card footer minimum monthly price from StoreKit") + .arg(minMonthlyDisplay)); + } service.insert(configKey::serviceDescription, description); services.replace(i, service); } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml index 0147a3520..ac9a387f5 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml @@ -222,14 +222,8 @@ PageType { if (Qt.platform.os === "ios" || IsMacOsNeBuild) { PageController.showBusyIndicator(true) var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : "" - var ok = ApiConfigsController.importPremiumFromAppStore(storeId) + ApiConfigsController.importPremiumFromAppStore(storeId) PageController.showBusyIndicator(false) - if (!ok) { - var endpoint = ApiServicesModel.getStoreEndpoint() - Qt.openUrlExternally(endpoint) - PageController.closePage() - PageController.closePage() - } return } if (plan.checkoutUrl) { @@ -238,15 +232,6 @@ PageType { PageController.closePage() return } - PageController.showBusyIndicator(true) - var importOk = ApiConfigsController.importService() - PageController.showBusyIndicator(false) - if (!importOk) { - var fallbackEndpoint = ApiServicesModel.getStoreEndpoint() - Qt.openUrlExternally(fallbackEndpoint) - PageController.closePage() - PageController.closePage() - } } } }