feat: additional parsing for storekit subscription plans

This commit is contained in:
vkamn
2026-04-01 14:25:47 +08:00
parent fa0ba4afd4
commit 6249eea905
4 changed files with 102 additions and 26 deletions
+36 -2
View File
@@ -94,20 +94,54 @@ public class StoreKit2Helper: NSObject {
product.priceFormatStyle.locale.currencyCode ?? "" 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<String>, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) { public func fetchProducts(identifiers: Set<String>, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) {
Task { Task {
do { do {
let products = try await Product.products(for: identifiers) let products = try await Product.products(for: identifiers)
let productDicts = products.map { product -> NSDictionary in let productDicts = products.map { product -> NSDictionary in
let currencyCode = storefrontCurrencyCode(for: product) let currencyCode = storefrontCurrencyCode(for: product)
return [ var dict: [String: Any] = [
"productId": product.id, "productId": product.id,
"title": product.displayName, "title": product.displayName,
"description": product.description, "description": product.description,
"price": "\(product.price)", "price": "\(product.price)",
"displayPrice": product.displayPrice, "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 fetchedIds = Set(products.map { $0.id })
let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) } let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) }
+9
View File
@@ -1157,6 +1157,15 @@ void IosController::fetchProducts(const QStringList &productIds,
m["displayPrice"] = QString::fromUtf8([p[@"displayPrice"] UTF8String]); m["displayPrice"] = QString::fromUtf8([p[@"displayPrice"] UTF8String]);
} }
m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] 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); outProducts.push_back(m);
} }
@@ -9,12 +9,14 @@
#include "ui/controllers/systemController.h" #include "ui/controllers/systemController.h"
#include "version.h" #include "version.h"
#include <QClipboard> #include <QClipboard>
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QEventLoop> #include <QEventLoop>
#include <QHash> #include <QHash>
#include <QJsonArray> #include <QJsonArray>
#include <QSet> #include <QSet>
#include <QVariantMap> #include <QVariantMap>
#include <limits>
#include "platforms/ios/ios_controller.h" #include "platforms/ios/ios_controller.h"
@@ -47,6 +49,9 @@ namespace
constexpr char subscriptionPlans[] = "subscription_plans"; constexpr char subscriptionPlans[] = "subscription_plans";
constexpr char storeProductId[] = "store_product_id"; constexpr char storeProductId[] = "store_product_id";
constexpr char priceLabel[] = "price_label"; 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 apiPayload[] = "api_payload";
constexpr char keyPayload[] = "key_payload"; constexpr char keyPayload[] = "key_payload";
@@ -249,6 +254,13 @@ namespace
} }
#if defined(Q_OS_IOS) || defined(MACOS_NE) #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) void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{ {
QJsonArray services = data.value(configKey::services).toArray(); QJsonArray services = data.value(configKey::services).toArray();
@@ -299,20 +311,25 @@ namespace
}); });
loop.exec(); loop.exec();
QHash<QString, QString> idToDisplayPrice; QHash<QString, StoreKitPlanQuote> idToQuote;
idToDisplayPrice.reserve(fetchedProducts.size()); idToQuote.reserve(fetchedProducts.size());
for (const QVariantMap &product : fetchedProducts) { for (const QVariantMap &product : fetchedProducts) {
const QString id = product.value(QStringLiteral("productId")).toString(); const QString id = product.value(QStringLiteral("productId")).toString();
if (id.isEmpty()) { if (id.isEmpty()) {
continue; continue;
} }
StoreKitPlanQuote quote;
QString display = product.value(QStringLiteral("displayPrice")).toString(); QString display = product.value(QStringLiteral("displayPrice")).toString();
if (display.isEmpty()) { if (display.isEmpty()) {
const QString price = product.value(QStringLiteral("price")).toString(); const QString price = product.value(QStringLiteral("price")).toString();
const QString currencyCode = product.value(QStringLiteral("currencyCode")).toString(); const QString currencyCode = product.value(QStringLiteral("currencyCode")).toString();
display = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode); 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) { for (int i = 0; i < services.size(); ++i) {
@@ -324,25 +341,56 @@ namespace
QJsonArray plans = description.value(configKey::subscriptionPlans).toArray(); QJsonArray plans = description.value(configKey::subscriptionPlans).toArray();
QJsonArray mergedPlans; QJsonArray mergedPlans;
double minMonthlyAmount = std::numeric_limits<double>::infinity();
QString minMonthlyDisplay;
for (const QJsonValue &planValue : plans) { for (const QJsonValue &planValue : plans) {
if (!planValue.isObject()) { if (!planValue.isObject()) {
continue; continue;
} }
QJsonObject planObject = planValue.toObject(); QJsonObject planObject = planValue.toObject();
const bool trial = planObject.value(configKey::isTrial).toBool();
const QString storeId = planObject.value(configKey::storeProductId).toString(); const QString storeId = planObject.value(configKey::storeProductId).toString();
if (storeId.isEmpty()) { if (storeId.isEmpty()) {
continue; continue;
} }
const auto priceIt = idToDisplayPrice.constFind(storeId);
if (priceIt == idToDisplayPrice.cend()) { const auto quoteIt = idToQuote.constFind(storeId);
if (quoteIt == idToQuote.cend()) {
continue; continue;
} }
planObject.insert(configKey::priceLabel, *priceIt);
const StoreKitPlanQuote &quote = *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); mergedPlans.append(planObject);
} }
plans = mergedPlans;
description.insert(configKey::subscriptionPlans, plans); description.insert(configKey::subscriptionPlans, mergedPlans);
if (minMonthlyAmount < std::numeric_limits<double>::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); service.insert(configKey::serviceDescription, description);
services.replace(i, service); services.replace(i, service);
} }
@@ -222,14 +222,8 @@ PageType {
if (Qt.platform.os === "ios" || IsMacOsNeBuild) { if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
PageController.showBusyIndicator(true) PageController.showBusyIndicator(true)
var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : "" var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : ""
var ok = ApiConfigsController.importPremiumFromAppStore(storeId) ApiConfigsController.importPremiumFromAppStore(storeId)
PageController.showBusyIndicator(false) PageController.showBusyIndicator(false)
if (!ok) {
var endpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(endpoint)
PageController.closePage()
PageController.closePage()
}
return return
} }
if (plan.checkoutUrl) { if (plan.checkoutUrl) {
@@ -238,15 +232,6 @@ PageType {
PageController.closePage() PageController.closePage()
return return
} }
PageController.showBusyIndicator(true)
var importOk = ApiConfigsController.importService()
PageController.showBusyIndicator(false)
if (!importOk) {
var fallbackEndpoint = ApiServicesModel.getStoreEndpoint()
Qt.openUrlExternally(fallbackEndpoint)
PageController.closePage()
PageController.closePage()
}
} }
} }
} }