Skip to content

Conversation

@sztomek
Copy link
Contributor

@sztomek sztomek commented Jan 13, 2026

Description

This PR itroduces the new installment plan. The fake datasoure (being used for dev and prototype builds) now offers the fake installments plan and the mapper has also been updated in case of real subscriptions.
Tests have been added.
New subscription has been added and activated on Google Play
SCR-20260114-odbz

P2: pdeCcb-aGD-p2
Fixes PCDROID-386
slack: p1767967198140579/1767884579.420229-slack-C0A0PJSLGBT
Fixes PCDROID-389

Testing Instructions

If you have a google account from one of the supported countires, you can give it a shot. Apply the debug-as-prod patch and see if you can find the intstallment plan on the upgrade screen.
I've spent a lot of time configuring my emulator then my real device, but i had no luck.
AI suggested to do these tricks:

  • Connect to VPN in one of the supported locations
  • Clear cache on the connected device/emulator:
 echo "🧹 Clearing Play Store cache..."
  adb shell pm clear com.android.vening
  adb shell pm clear com.google.android.gms
  • Wait 30 seconds then reboot the device
  • After reboot, log in on play again
  • Verify that you're country is correct: Settings>General>Account and device preferences

As i said, I had no luck changing my country to Spain... According to the AI, Google determines your country by the combination of factors:

  • IP address
  • Geoloc (GPS data)
  • Billing address of the signed account

The first two checked out to me, the last one didn't. If i had changed the country / address of my account, that change would require 48 hours to take effect.
I'm out of ideas.

Screenshots or Screencast

nothing to see here either.

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml
  • Any jetpack compose components I added or changed are covered by compose previews
  • I have updated (or requested that someone edit) the spreadsheet to reflect any new or changed analytics.

@sztomek sztomek added this to the 8.4 milestone Jan 13, 2026
Copilot AI review requested due to automatic review settings January 13, 2026 07:57
@sztomek sztomek requested a review from a team as a code owner January 13, 2026 07:57
@sztomek sztomek added the [Type] Feature Adding a new feature. label Jan 13, 2026
@sztomek sztomek requested review from MiSikora and removed request for a team January 13, 2026 07:57
@sztomek sztomek added the [Area] Subscriptions Plus or Patron issue label Jan 13, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces support for a new installment plan subscription option that allows users to pay yearly subscriptions in monthly installments. The feature is controlled by the NEW_INSTALLMENT_PLAN feature flag and is currently enabled for debug/prototype builds only.

Changes:

  • Added installment plan support to the payment system with proper detection and handling via billing cycle and pricing schedule checks
  • Updated upgrade screen UI to display installment plans with appropriate pricing display (e.g., "$3.33/month for 12 months")
  • Added comprehensive test coverage for subscription models and installment plan logic

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt Adds NEW_INSTALLMENT_PLAN feature flag, enabled by default for debug/prototype builds
modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/extensions/PaymentExtensions.kt Adds utility function to select yearly plan based on feature flag, returning installment plan when available
modules/services/servers/src/test/java/au/com/shiftyjelly/pocketcasts/servers/sync/SubscriptionModelTest.kt Adds tests for isInstallment field mapping in subscription responses
modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/sync/SubscriptionModel.kt Adds isInstallment field to SubscriptionResponse with default value false
modules/services/payment/src/test/kotlin/au/com/shiftyjelly/pocketcasts/payment/SubscriptionPlansTest.kt Adds tests for installment plan creation and retrieval
modules/services/payment/src/main/kotlin/au/com/shiftyjelly/pocketcasts/payment/billing/BillingPaymentDataSource.kt Adds installment product ID to the list of products queried from Google Play Billing
modules/services/payment/src/main/kotlin/au/com/shiftyjelly/pocketcasts/payment/PaymentClient.kt Adds special handling to map installment product ID to Plus yearly plan key
modules/services/payment/src/main/kotlin/au/com/shiftyjelly/pocketcasts/payment/FakePaymentDataSource.kt Adds fake installment plan product for testing with monthly pricing schedule
modules/services/payment/src/main/kotlin/au/com/shiftyjelly/pocketcasts/payment/Data.kt Adds installment plan storage and retrieval methods to SubscriptionPlans, defines installment product ID constant
modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/Subscription.kt Adds isInstallment field with default value false to Subscription model
modules/services/localization/src/main/res/values/strings.xml Adds string resources for installment plan pricing display formats
modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/viewmodel/OnboardingUpgradeFeaturesViewModel.kt Updates yearly plan selection to use installment plans when feature flag is enabled
modules/features/account/src/main/java/au/com/shiftyjelly/pocketcasts/account/onboarding/components/SubscriptionPlanRow.kt Adds installment plan detection and custom UI formatting, hides savings percentage for installment plans

Comment on lines 19 to 33
fun SubscriptionPlans.getYearlyPlanWithFeatureFlag(
tier: SubscriptionTier,
): SubscriptionPlan.Base {
val isInstallmentEnabled = FeatureFlag.isEnabled(Feature.NEW_INSTALLMENT_PLAN)

if (tier == SubscriptionTier.Plus && isInstallmentEnabled) {
// Try to get the installment plan (may not exist if user not in supported country)
val installmentPlan = getInstallmentPlan()
if (installmentPlan != null) {
return installmentPlan
}
}

return getBasePlan(tier, BillingCycle.Yearly)
}
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new getYearlyPlanWithFeatureFlag extension function lacks test coverage. Consider adding unit tests to verify:

  1. Returns installment plan when feature flag is enabled for Plus tier and installment plan is available
  2. Falls back to regular yearly plan when feature flag is enabled for Plus tier but installment plan is not available
  3. Returns regular yearly plan when feature flag is disabled for Plus tier
  4. Returns regular yearly plan for Patron tier (since installment plans only exist for Plus tier)

These tests should verify the function's behavior with different feature flag states and tier combinations.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@MiSikora MiSikora left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you split this PR into 2? One that integrates with the Play Console and one that integrates mapped plans with the UI.

It will be easier to review since changes to the :payment module require more strict and careful review than the UI changes and integration.

@sztomek sztomek force-pushed the feat/installment-plan branch from 28cc3cc to f571fb9 Compare January 13, 2026 20:08
@sztomek
Copy link
Contributor Author

sztomek commented Jan 13, 2026

@MiSikora updated the PR + the description

@sztomek sztomek requested a review from MiSikora January 13, 2026 20:10
Copy link
Contributor

@MiSikora MiSikora left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I’m missing something here and you’ve already done the relevant research, but I haven’t been able to find it. If that’s the case, please point me to it. In any case, I think we may be building on the wrong assumptions.

com.android.billingclient.api.ProductDetails exposes getInstallmentPlanDetails(), which returns InstallmentPlanDetails, and that data should feed into creating our au.com.shiftyjelly.pocketcasts.payment.Product. In this PR, I don’t see the installment-plan data being mapped or integrated, which means we would effectively be ignoring the source of truth.

Before integrating this into the app, it would be good to first ensure that we correctly identify and extract installment-plan details in BillingPaymentMapper. Otherwise, we risk designing incorrect data structures and operating on the wrong assumptions further down the line.

We should start by adding an installment plan in the Play Console. Once it’s enabled, use real ProductDetails output to extract InstallmentPlanDetails. Then redesign our models/structures to accommodate the data as needed.

From my perspective, it may also make sense to split this work into two separate PRs:

  1. Integrate with the Play Console and extract installment data into au.com.shiftyjelly.pocketcasts.payment.Product.
  2. Map installment products to new subscription plans. This would likely require changes to keys, introducing a new SubscriptionPlan subtype, and adjusting logic in the affected areas.

On testing: the docs mention that installment plans are only available in specific countries. I’m hoping license tester accounts bypass that limitation, but I’m not certain. If they don’t, we should clarify with Google what the recommended approach is for validating the integration end-to-end on our side.

@ConsistentCopyVisibility
data class SubscriptionPlans private constructor(
private val plans: Map<SubscriptionPlan.Key, PaymentResult<SubscriptionPlan>>,
private val installmentPlan: SubscriptionPlan.Base?,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 This change undermines the overall design of the class and is the source of additional workarounds or special handling for installment plans. Each subscription plan should be retrievable via a single, well-defined key. Doing this may require adding to the key type an additional property to distinguish installment plans and potentially introducing a dedicated SubscriptionPlan subtype.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the PR in sync with your suggestion, please check again

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you miss my main comment #4887 (review)?

Unless I’m mistaken, we shouldn’t be making changes to PaymentClient yet. The first step should be to retrieve and parse installment-plan data in BillingPaymentMapper, and only then decide what needs to change in PaymentClient.

ProductDetails.getInstallmentPlanDetails() returns InstallmentPlanDetails, which should allow us to correctly classify a product as an installment plan. For example, the commitment payment count. Without mapping this data first, we risk wiring PaymentClient against assumptions rather than the actual Billing API output.

@sztomek sztomek changed the title [Installment Plan] Add fake plan and update upgrade screen UI [Installment Plan] Add fake installments plan Jan 14, 2026
Copilot AI review requested due to automatic review settings January 14, 2026 14:59
@sztomek sztomek changed the title [Installment Plan] Add fake installments plan [Installment Plan] Add new installment plan Jan 14, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment on lines +316 to 357
val normalizedNewProductId = if (newPlanKey.productId == SubscriptionPlan.PLUS_YEARLY_INSTALLMENT_PRODUCT_ID) {
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID
} else {
newPlanKey.productId
}

SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> when (newPlanKey.productId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
else -> null
val normalizedCurrentPlanId = if (currentPlanId == SubscriptionPlan.PLUS_YEARLY_INSTALLMENT_PRODUCT_ID) {
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID
} else {
currentPlanId
}

SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> when (newPlanKey.productId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_PRORATED_PRICE
else -> null
}
when (normalizedCurrentPlanId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> when (normalizedNewProductId) {
SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> ReplacementMode.CHARGE_PRORATED_PRICE
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
else -> null
}

SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> when (normalizedNewProductId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_FULL_PRICE
else -> null
}

SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> when (normalizedNewProductId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> ReplacementMode.CHARGE_PRORATED_PRICE
else -> null
}

SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> when (normalizedNewProductId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
else -> null
}

SubscriptionPlan.PATRON_YEARLY_PRODUCT_ID -> when (newPlanKey.productId) {
SubscriptionPlan.PLUS_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PATRON_MONTHLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
SubscriptionPlan.PLUS_YEARLY_PRODUCT_ID -> ReplacementMode.WITH_TIME_PRORATION
else -> null
}
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normalization logic for installment plan handling in the replacement mode calculation lacks test coverage. The new code normalizes both current and new product IDs to treat installment plans as regular yearly plans for replacement mode purposes. This critical billing logic should have dedicated test cases to verify:

  • Switching from regular Plus Yearly to installment Plus Yearly (and vice versa)
  • Switching from another plan to installment Plus Yearly
  • Switching from installment Plus Yearly to another plan

Copilot uses AI. Check for mistakes.
@sztomek sztomek requested a review from MiSikora January 14, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] Subscriptions Plus or Patron issue [Type] Feature Adding a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants