-
Notifications
You must be signed in to change notification settings - Fork 275
[Installment Plan] Add new installment plan #4887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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 |
| 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) | ||
| } |
Copilot
AI
Jan 13, 2026
There was a problem hiding this comment.
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:
- Returns installment plan when feature flag is enabled for Plus tier and installment plan is available
- Falls back to regular yearly plan when feature flag is enabled for Plus tier but installment plan is not available
- Returns regular yearly plan when feature flag is disabled for Plus tier
- 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.
MiSikora
left a comment
There was a problem hiding this 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.
28cc3cc to
f571fb9
Compare
|
@MiSikora updated the PR + the description |
MiSikora
left a comment
There was a problem hiding this 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:
- Integrate with the Play Console and extract installment data into
au.com.shiftyjelly.pocketcasts.payment.Product. - Map installment products to new subscription plans. This would likely require changes to keys, introducing a new
SubscriptionPlansubtype, 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.
modules/services/payment/src/main/kotlin/au/com/shiftyjelly/pocketcasts/payment/Data.kt
Show resolved
Hide resolved
| @ConsistentCopyVisibility | ||
| data class SubscriptionPlans private constructor( | ||
| private val plans: Map<SubscriptionPlan.Key, PaymentResult<SubscriptionPlan>>, | ||
| private val installmentPlan: SubscriptionPlan.Base?, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
| 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 | ||
| } |
Copilot
AI
Jan 14, 2026
There was a problem hiding this comment.
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
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
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:
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:
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
./gradlew spotlessApplyto automatically apply formatting/linting)modules/services/localization/src/main/res/values/strings.xml