diff --git a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls index 288983627b..45b72a15fa 100644 --- a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls +++ b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls @@ -468,6 +468,41 @@ type BundleYearlySavings { """ bundleDiscountCoversFullPeriod: Boolean! } +""" +SHA-256-hashed user data for Facebook Conversions API (CAPI). +All non-null field values are hex-encoded SHA-256 hashes of the normalized plaintext. +Normalization follows https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters +""" +type CapiUserData { + """ + Hashed email (em) + """ + em: String! + """ + Hashed Gmail-normalized email, only present for Gmail addresses (gem) + """ + gem: String + """ + Hashed first name (fn) + """ + fn: String! + """ + Hashed last name (ln) + """ + ln: String! + """ + Hashed postal/zip code, null if unavailable (zp) + """ + zp: String + """ + Hashed city, null if unavailable (ct) + """ + ct: String + """ + Hashed country code, e.g. 'se' (co) + """ + co: String! +} type CarItemNotification { message: String! } @@ -1687,15 +1722,15 @@ returned as ExtendedItemDiscount """ type ExtendedItemDiscount { """ - General discount information + General discount information """ itemDiscount: ItemDiscount! """ - Monthly reduction applied by the discount. It's a negative number + Monthly reduction applied by the discount. It's a negative number """ amount: Money! """ - Whether discount is on a pending state or not + Whether discount is on a pending state or not """ isPending: Boolean! } @@ -1728,6 +1763,11 @@ type ExternalInsurer { displayName: String! insurelyId: String } +type FetchedExternalInsurance { + displayName: String! + subtitle: String + insurer: ExternalInsurer! +} type FirstVetAction { sections: [FirstVetSection!]! } @@ -2377,6 +2417,9 @@ type LinkInfo { type Location { street: String } +type ManuallyChargeMemberMutationOutput { + userError: UserError +} """ A 'Member' is the central user-like concept of our platform, referring to someone who has bought insurance with Hedvig and is now as we call is a "member". @@ -2409,6 +2452,11 @@ type Member { Payment information for this member. """ paymentInformation: MemberPaymentInformation! + """ + Null if the latest charge was successful or self manual charge is not allowed. + Id of the latest charge if it failed and self manual charge allowed. + """ + missedChargeIdToChargeManually: UUID paymentMethods: MemberPaymentMethods! conversations: [Conversation!]! legacyConversation: Conversation @@ -2419,6 +2467,8 @@ type Member { claims: [Claim!]! claimsActive: [Claim!]! claimsHistory: [Claim!]! + partnerClaimsActive: [PartnerClaim!]! + partnerClaimsHistory: [PartnerClaim!]! firstName: String! lastName: String! ssn: String @@ -2729,6 +2779,22 @@ type MemberPaymentAvailablePaymentMethod { True if the member can set up this payment method for payout. """ supportsPayout: Boolean! + """ + True if this method is already ACTIVE for member and can be chosen as default directly without setup, false if + this is a new payment method that the member has not yet set up. + If this is true, then the `details` field will be populated with the payment method details. If this is false, then + the `details` field will be null since the member has not yet set up this payment method. + If true then this method can be set up as default directly by calling `paymentMethodSetDefaultPayin` or + `paymentMethodSetDefaultPayout` mutation depending on if it's a payin or payout method. If false, then the + corresponding mutation for setting up this payment method should be called, eg. `paymentMethodSetupTrustly`, + `paymentMethodSetupSwishPayin` etc. + """ + isActive: Boolean! + """ + For already connected and ACTIVE methods, ie isActive=true, specific details of the actual connection - e.g. a bank + account reference, phone number for swish, or email/kivra for invoice. + """ + details: PaymentMethodDetails } type MemberPaymentChargeMethodInfo { """ @@ -2801,48 +2867,76 @@ type MemberPaymentInformation { } type MemberPaymentMethod { """ - The unique id of the payment method. This id is used for switching default and revoking payment methods. - """ - id: ID! - """ - Payment provider, eg Trustly, Swish, Nordea, Kivra etc. + Payment provider, eg Trustly, Swish, Nordea, Kivra etc. + This is used as the "identifier" of the payment method since there can only be one ACTIVE or PENDING payment method + per provider. """ provider: MemberPaymentProvider! """ - The payment method status - ACTIVE, PENDING, or PENDING_DEFAULT. - PENDING_DEFAULT means the payment method is awaiting activation and will become default once activated. + The payment method status - ACTIVE, PENDING. + If ACTIVE, the payment method is ready to use for payins or payouts depending on if it's a payin or payout method. + If PENDING, the payment method has been set up but is still awaiting activation and cannot be used for payins or + payouts until then. Once activated, the status will change to ACTIVE. """ status: MemberPaymentMethodStatus! """ - True if this is the default payment method. Only one ACTIVE payment method can be default at a time. - If status is PENDING then payment method will become the default once activated. + This is 'true' for only one of the members ACTIVE methods which is the default payment method that will be used for + charging or payout the member. For PENDING methods, this can also be 'true' if the member has chosen to set up this + payment method as default during the setup process. """ isDefault: Boolean! """ - Specific details of the actual connection - e.g. a bank account reference, phone number for swish, - or email/kivra for invoice. + Specific details of the actual connection if method is ACTIVE - e.g. a bank account reference, phone number for swish, + or email/kivra for invoice. If method is PENDING, then this field will be null since the member has not yet set up + this payment method. """ - details: PaymentMethodDetails! + details: PaymentMethodDetails } type MemberPaymentMethods { """ - List of active and pending payment payin methods for this member. + List of all member's ACTIVE and PENDING payment payin methods. + A member can have multiple ACTIVE payment methods with these constraints: + - Only one ACTIVE payment method per provider, eg. one Trustly, one Swish, one Nordea etc. + - Only one ACTIVE payment method can be default at a time. + A member can have multiple PENDING payment methods with these constraints: + - Only one PENDING payment method per provider, eg. one Trustly, one Swish, one Nordea etc. + So there can exist max two payment methods per provider, one ACTIVE and one PENDING. + If a PENDING payment method has isDefault=true, then it will become the default ACTIVE payment method once activated. """ payinMethods: [MemberPaymentMethod!]! """ - List of active and pending payment payout methods for this member. + List of all member's ACTIVE and PENDING payment payout methods. + A member can have multiple ACTIVE payment methods with these constraints: + - Only one ACTIVE payment method per provider, eg. one Trustly, one Swish, one Nordea etc. + - Only one ACTIVE payment method can be default at a time. + A member can have multiple PENDING payment methods with these constraints: + - Only one PENDING payment method per provider, eg. one Trustly, one Swish, one Nordea etc. + So there can exist max two payment methods per provider, one ACTIVE and one PENDING. + If a PENDING payment method has isDefault=true, then it will become the default ACTIVE payment method once activated. """ payoutMethods: [MemberPaymentMethod!]! """ - The default payment method for payin if any. + The default payment method to use for payins if any. + Note that there can exist a PENDING payment method in `payinMethods` list with `isDefault`=true, in that case this default + payment method will be replaced by it once the pending method is activated. """ defaultPayinMethod: MemberPaymentMethod """ - The default payment method for payout if any. + The default payment method to use for payouts if any. + Note that there can exist a PENDING payment method in `payoutMethods` list with `isDefault`=true, in that case this default + payment method will be replaced by it once the pending method is activated. """ defaultPayoutMethod: MemberPaymentMethod """ - The available payment methods that the member can choose from when setting up a new payment method. + The available payment methods that the member can choose from when setting up a new payment method. + This list can include both payment methods that the member has already set up and new payment methods that the + member has not yet set up but are available to them. For already set up payment methods, the `isActive` field will + be true and the `details` field will be populated with the payment method details. For new payment methods that the + member has not yet set up, the `isActive` field will be false and the `details` field will be null. + If member picks a new payment method to set up, the corresponding mutation for setting up that payment method should + be called, eg. `paymentMethodSetupTrustly`, `paymentMethodSetupSwishPayin` etc. + If member picks an already set up payment method to set up as default, then `paymentMethodSetDefaultPayin` or + `paymentMethodSetDefaultPayout` mutation should be called depending on if it's a payin or payout method. """ availableMethods: [MemberPaymentAvailablePaymentMethod!]! """ @@ -3389,12 +3483,13 @@ input MoveToHouseInput { } type Mutation { registerDirectDebit2(clientContext: RegisterDirectDebitClientContext2): DirectDebitResponse2! + manuallyChargeMember: ManuallyChargeMemberMutationOutput! """ Setup invoice payment method for the member. Kivra will be used as the provider if supported, else mail. """ - paymentMethodSetupInvoicePayin(input: PaymentMethodSetupInvoicePayinInput!): PaymentMethodSetupOutput! + paymentMethodSetupInvoicePayin: PaymentMethodSetupOutput! """ - Setup Trustly payment payin and payout method for the member. + Setup Trustly payment payin and payout method for the member. Requires member consent via redirect to Trustly URL in response. """ paymentMethodSetupTrustly(input: PaymentMethodSetupTrustlyInput!): PaymentMethodSetupOutput! """ @@ -3406,17 +3501,19 @@ type Mutation { """ paymentMethodSetupSwishPayout(input: PaymentMethodSetupSwishInput!): PaymentMethodSetupOutput! """ - Setup Swish payin method for the member. + Setup Swish payin method for the member. Requires member consent in Swish app. """ paymentMethodSetupSwishPayin(input: PaymentMethodSetupSwishInput!): PaymentMethodSetupOutput! """ - Revoke an active payment method. The member will be required to set up a new payment method if they revoke their default one. + A member can have multiple ACTIVE payment methods where one of those is default. This mutation changes the + members default payment method for charging to any of his/hers other active payment methods. """ - paymentMethodRevoke(id: ID!): UserError + paymentMethodSetDefaultPayin(provider: MemberPaymentProvider!): UserError """ - Set an active payment method as default. + A member can have multiple ACTIVE payment methods where one of those is default. This mutation changes the + members default payment method for payouts to any of his/hers other active payment methods. """ - paymentMethodSetDefault(id: ID!): UserError + paymentMethodSetDefaultPayout(provider: MemberPaymentProvider!): UserError """ Start a conversation. This is effectively creating one, but with two slight differences from a regular "create something"-mutation: @@ -3545,7 +3642,7 @@ type Mutation { Update the raw insurance-related data for this `PriceIntent`. This data is mostly related to the insured object itself, and not the "holder" of the insurance. """ - priceIntentDataUpdate(priceIntentId: UUID!, data: PricingFormData!): PriceIntentMutationOutput! + priceIntentDataUpdate(priceIntentId: UUID!, data: PricingFormData!, applySuggestedData: Boolean): PriceIntentMutationOutput! """ Associate a specific Insurely `dataCollectionId` from lookup-service with this PriceIntent. """ @@ -3553,8 +3650,10 @@ type Mutation { """ Confirm this PriceIntent, which will use the current data (and likely `ShopSession.customer`) to generate `ProductOffers` that can be added to the cart. + Optional `attribution` is merged field-by-field over `ShopSession.attribution` for this confirm only (quotes and + `PriceIntentConfirmed` events use the merged result). """ - priceIntentConfirm(priceIntentId: UUID!): PriceIntentMutationOutput! + priceIntentConfirm(priceIntentId: UUID!, attribution: PriceIntentConfirmAttributionInput): PriceIntentMutationOutput! """ Change the start date of the given `ProductOffer`s by their ID. This is used because it's common to want to change the start date AFTER getting the offer. @@ -3676,6 +3775,22 @@ type Mutation { """ upsellTravelAddonActivate(quoteId: ID!, addonId: ID!): UpsellTravelAddonActivationOutput! } +type PartnerClaim { + id: ID! + externalId: String! + exposureDisplayName: String + status: ClaimStatus + submittedAt: Date + payoutAmount: Money + associatedTypeOfContract: String + claimType: String + handlerEmail: String + displayItems: [ClaimDisplayItem!]! + """ + Terms & conditions for the claim found using claims contractId and dateOfOccurrence, otherwise null. + """ + productVariant: ProductVariant +} type PartnerData { sas: SasPartnerData } @@ -3684,7 +3799,7 @@ type PartnerWidgetTrial { } type PaymentMethodBankAccountDetails { """ - The bank account reference - e.g. clearing number and account number. + The bank account reference - e.g. clearing number + account number. """ account: String! """ @@ -3707,21 +3822,7 @@ type PaymentMethodInvoiceDetails { """ email: String } -input PaymentMethodSetupInvoicePayinInput { - """ - Set up invoice payment method as default. - """ - setAsDefaultPayout: Boolean! -} input PaymentMethodSetupNordeaPayoutInput { - """ - Set up Nordea payout method as default. - """ - setAsDefault: Boolean! - """ - The clearing number for member's bank account. - """ - clearingNumber: String! """ The account number for member's bank account. """ @@ -3732,6 +3833,10 @@ type PaymentMethodSetupOutput { The status of the setup process. If FAILED the reason for failure can be found in the `error` field. """ status: PaymentMethodSetupStatus! + """ + The order id for the payment method setup order if SUCCESSFUL. + """ + orderId: ID """ Url to redirect the member to if any. """ @@ -3756,24 +3861,12 @@ enum PaymentMethodSetupStatus { FAILED } input PaymentMethodSetupSwishInput { - """ - Set up Swish payment method as default. - """ - setAsDefault: Boolean! """ The Swish mobile number to use for payout or payin. """ phoneNumber: String! } input PaymentMethodSetupTrustlyInput { - """ - Set up Trustly payment method as default for payin. - """ - setAsDefaultPayin: Boolean! - """ - Set up Trustly payment method as default for payout. - """ - setAsDefaultPayout: Boolean! """ The URL to redirect the member back to after a successful setup after Trustly onboarding. """ @@ -3873,11 +3966,11 @@ type PriceIntent { """ product: Product! """ - Submitted user form data. + UI-safe masked form data. PII fields (street, zipCode, city) are always masked. """ data: PricingFormData! """ - Data submitted in other places or inferred from other data points + Masked, uncommitted form data from automatic lookup (e.g. SPAR address, trial data). PII fields are masked the same way as 'data'. """ suggestedData: PricingFormData! """ @@ -3905,6 +3998,15 @@ type PriceIntent { When 'true' it means user has gone trough Insurely flow with that price intent """ hasCollectedInsurelyData: Boolean! + """ + List of external insurances fetched via Insurely that correspond to products Hedvig offers. + Null when no Insurely data collection has been associated with this price intent. + """ + fetchedExternalInsurances: [FetchedExternalInsurance!] + """ + When 'true' all required form data has been provided and the price intent can be confirmed. + """ + isReadyToConfirm: Boolean! } enum PriceIntentAnimal { CAT @@ -3915,6 +4017,14 @@ type PriceIntentAnimalBreed { displayName: String! isMixedBreed: Boolean! } +input PriceIntentConfirmAttributionInput { + attributedTo: String + initiatedFrom: String + userFlow: String + flowSource: String + experiments: [ShopSessionExperimentInput!] + trackingData: JSON +} input PriceIntentCreateInput { shopSessionId: UUID! """ @@ -4065,7 +4175,7 @@ type ProductOffer { """ priceIntentId: UUID """ - The form data used to generate the offer + UI-safe masked form data used to generate the offer. PII fields (street, zipCode, city) are masked when address came from registration address lookup. """ priceIntentData: PricingFormData! """ @@ -4234,6 +4344,11 @@ if the user has input enough information to generate it. type ProductRecommendation { product: Product! offer: ProductOffer + """ + External insurance data from Insurely, available even when no offer could be generated. + Null when no Insurely data collection is associated with the session. + """ + externalInsurance: RecommendationExternalInsurance } type ProductVariant { """ @@ -4306,6 +4421,7 @@ type Query { """ conversation(id: UUID!): Conversation claim(id: ID!): Claim + partnerClaim(id: ID!): PartnerClaim claimIntent(id: ID!): ClaimIntent! claimIntentFormFieldSearch(input: ClaimIntentFormFieldSearchInput!): ClaimIntentFormFieldSearchOutput! personalInformation(input: PersonalInformationInput!): PersonalInformation @@ -4366,6 +4482,32 @@ type Query { """ addonOfferCost(quoteId: ID!, selectedAddonIds: [ID!]!): ItemCost! } +type RecommendationExternalInsurance { + """ + Display name of the external insurance product + """ + displayName: String! + """ + The external insurer + """ + insurer: ExternalInsurer! + """ + Monthly price of the external insurance. Null if not available. + """ + price: Money + """ + Contextual subtitle (e.g. address, registration number, pet name) + """ + subtitle: String + """ + Renewal date of the external policy. Null when not provided. + """ + renewalDate: Date + """ + Insurely data collection ID + """ + dataCollectionId: String! +} type RecommendedCrossSell { crossSell: CrossSell! bannerText: String! @@ -4614,6 +4756,12 @@ type ShopSessionOutcome { Note that this will not contain and `PendingContract`s. """ createdContracts: [Contract!]! + """ + Pre-hashed Facebook CAPI user data (SHA-256 hex, lowercase) for this signed session. + Fields follow Meta's Conversions API customer information parameter spec. + Requires authentication (inherits MemberAccessible from the outcome). + """ + capiUserData: CapiUserData! } type ShopSessionSigning { id: UUID! diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt index 6eab5f8368..1397dff029 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt @@ -47,6 +47,7 @@ import com.hedvig.android.logger.LogPriority import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.core.HedvigDeepLinkContainer import com.hedvig.android.navigation.core.allDeepLinkUriPatterns +import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationServiceProvider import com.hedvig.android.theme.Theme import com.stylianosgakis.navigation.recents.url.sharing.provideAssistContent import java.util.Locale @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { private val logoutUseCase: LogoutUseCase by inject() private val getMemberAuthorizationCodeUseCase: GetMemberAuthorizationCodeUseCase by inject() + private val missedPaymentNotificationServiceProvider: MissedPaymentNotificationServiceProvider by inject() private var navController: NavController? = null @@ -164,6 +166,7 @@ class MainActivity : AppCompatActivity() { externalNavigator = externalNavigator, logoutUseCase = logoutUseCase, getMemberAuthorizationCodeUseCase = getMemberAuthorizationCodeUseCase, + missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, ) } } diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt index 7e710757f0..fc140e9a50 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigApp.kt @@ -39,6 +39,7 @@ import com.hedvig.android.core.demomode.Provider import com.hedvig.android.data.paying.member.GetOnlyHasNonPayingContractsUseCase import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.feature.cross.sell.sheet.CrossSellSheet +import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationServiceProvider import com.hedvig.android.feature.login.navigation.LoginDestination import com.hedvig.android.featureflags.FeatureManager import com.hedvig.android.language.LanguageService @@ -84,6 +85,7 @@ internal fun HedvigApp( externalNavigator: ExternalNavigator, logoutUseCase: LogoutUseCase, getMemberAuthorizationCodeUseCase: GetMemberAuthorizationCodeUseCase, + missedPaymentNotificationServiceProvider: MissedPaymentNotificationServiceProvider, ) { val hedvigAppState = rememberHedvigAppState( windowSizeClass = windowSizeClass, @@ -91,6 +93,7 @@ internal fun HedvigApp( getOnlyHasNonPayingContractsUseCase = getOnlyHasNonPayingContractsUseCase, featureManager = featureManager, navHostController = navHostController, + missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, ) val darkTheme = hedvigAppState.darkTheme HedvigTheme(darkTheme = darkTheme) { diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt index 843328d3c8..900a6fb215 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppState.kt @@ -25,6 +25,7 @@ import com.hedvig.android.core.demomode.Provider import com.hedvig.android.data.paying.member.GetOnlyHasNonPayingContractsUseCase import com.hedvig.android.data.settings.datastore.SettingsDataStore import com.hedvig.android.feature.forever.navigation.ForeverDestination +import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationServiceProvider import com.hedvig.android.feature.help.center.navigation.helpCenterCrossSellBottomSheetPermittingDestinations import com.hedvig.android.feature.home.home.navigation.HomeDestination import com.hedvig.android.feature.home.home.navigation.homeCrossSellBottomSheetPermittingDestinations @@ -62,6 +63,7 @@ internal fun rememberHedvigAppState( getOnlyHasNonPayingContractsUseCase: Provider, featureManager: FeatureManager, navHostController: NavHostController, + missedPaymentNotificationServiceProvider: MissedPaymentNotificationServiceProvider, coroutineScope: CoroutineScope = rememberCoroutineScope(), ): HedvigAppState { NavigationViewTrackingEffect(navController = navHostController) @@ -73,6 +75,7 @@ internal fun rememberHedvigAppState( settingsDataStore, getOnlyHasNonPayingContractsUseCase, featureManager, + missedPaymentNotificationServiceProvider, ) { HedvigAppState( navController = navHostController, @@ -81,6 +84,7 @@ internal fun rememberHedvigAppState( settingsDataStore = settingsDataStore, getOnlyHasNonPayingContractsUseCase = getOnlyHasNonPayingContractsUseCase, featureManager = featureManager, + missedPaymentNotificationServiceProvider = missedPaymentNotificationServiceProvider, ) } } @@ -93,6 +97,7 @@ internal class HedvigAppState( private val settingsDataStore: SettingsDataStore, getOnlyHasNonPayingContractsUseCase: Provider, featureManager: FeatureManager, + missedPaymentNotificationServiceProvider: MissedPaymentNotificationServiceProvider, ) { val currentDestination: NavDestination? @Composable get() = navController.currentBackStackEntryAsState().value?.destination @@ -162,6 +167,15 @@ internal class HedvigAppState( ), ) + val showPaymentsBadge: StateFlow = missedPaymentNotificationServiceProvider + .prodImpl + .showRedDotNotification() + .stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5_000), + false, + ) + /** * UI logic for navigating to a top level destination in the app. Top level destinations have * only one copy of the destination of the back stack, and save and restore state whenever you diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt index f9514c3e01..b2f395c3e8 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/HedvigAppUi.kt @@ -44,6 +44,7 @@ import com.hedvig.android.design.system.hedvig.tokens.MotionTokens import com.hedvig.android.language.LanguageService import com.hedvig.android.navigation.activity.ExternalNavigator import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import com.hedvig.android.navigation.core.TopLevelGraph import hedvig.resources.EXIT_DEMO_MODE_BUTTON import hedvig.resources.Res import org.jetbrains.compose.resources.stringResource @@ -65,6 +66,7 @@ internal fun HedvigAppUi( logoutUseCase: LogoutUseCase, ) { val isDemoMode by demoManager.isDemoMode().collectAsState(false) + val showPaymentsBadge by hedvigAppState.showPaymentsBadge.collectAsState() val globalSnackBarState = rememberGlobalSnackBarState() Box(Modifier.fillMaxSize()) { Surface( @@ -76,6 +78,9 @@ internal fun HedvigAppUi( topLevelGraphs = hedvigAppState.topLevelGraphs.collectAsState().value, currentDestination = hedvigAppState.currentDestination, onNavigateToTopLevelGraph = hedvigAppState::navigateToTopLevelGraph, + getShowNotificationBadge = { graph -> + if (graph == TopLevelGraph.Payments) showPaymentsBadge else false + }, ) { Box( propagateMinConstraints = true, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/ui/NavigationSuite.kt b/app/app/src/main/kotlin/com/hedvig/android/app/ui/NavigationSuite.kt index c99c32aa89..070b3e2f6d 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/ui/NavigationSuite.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/ui/NavigationSuite.kt @@ -30,6 +30,7 @@ internal fun NavigationSuite( currentDestination: NavDestination?, onNavigateToTopLevelGraph: (TopLevelGraph) -> Unit, modifier: Modifier = Modifier, + getShowNotificationBadge: (TopLevelGraph) -> Boolean = { false }, content: @Composable RowScope.() -> Unit, ) { Column(modifier) { @@ -49,6 +50,7 @@ internal fun NavigationSuite( onNavigateToDestination = onNavigateToTopLevelGraph, getIsCurrentlySelected = currentDestination::isTopLevelGraphInHierarchy, isExtraTall = navigationSuiteType == NavigationSuiteType.NavigationRailXLarge, + getShowNotificationBadge = getShowNotificationBadge, ) } content() @@ -62,6 +64,7 @@ internal fun NavigationSuite( destinations = topLevelGraphs, onNavigateToDestination = onNavigateToTopLevelGraph, getIsCurrentlySelected = currentDestination::isTopLevelGraphInHierarchy, + getShowNotificationBadge = getShowNotificationBadge, ) } } diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index 7cea7bbfd9..8131042d0e 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -80,8 +80,6 @@ Skanna QR-koden med BankID-appen på din telefon, eller logga in med din e-postadress nedan. Utbetalning till ett svenskt bankkonto Bankkonto - Clearing - Direktutbetalning via Trustly Hej - just nu har vi lunch-stängt i chatten. Vi svarar så fort vi kan när vi öppnar igen klockan 13. Tack för ditt meddelande. Vi svarar så snart som möjligt. Just nu är chatten stängd. Vi svarar så fort vi kan när vi öppnar. @@ -147,7 +145,7 @@ Ange byggår Byggår Du + %1$d - Ändra konto + Ändra utbetalningskonto Ändra skyddsnivå Chatta med en specialist Om det inte fungerar eller om du har några andra frågor, vänligen meddela oss så hjälper en av våra specialister till inom kort! @@ -517,7 +515,7 @@ Fakturan skickas till din Kivra-inkorg 14 dagar före förfallodatumet varje månad. Tillgänglighet Juridisk information - Juridisk information + Legal information Personuppgifter Fråga angående skadeanmälan - Fordon reg. %1$s Välj land och språk @@ -608,7 +606,19 @@ Om du uppdaterar ditt bankkonto nära den %1$s kan det ta några extra dagar innan pengarna dras. Denna betalning misslyckades och lades till i din betalning den %1$s . Betalningshistorik + Betalning pågår + Det kan ta upp till 5 bankdagar innan betalningen syns Betalningssätt + Att betala: %1$s + Vi kunde inte dra betalningen från ditt bankkonto. Betala för att undvika avbrott i ditt skydd. + Granska betalning + Betala för att undvika avbrott + Förfallodag + Se till att det finns tillräckliga medel på kontot för att genomföra betalningen + Betala %1$s + Försenad sedan %1$s + Visa betalningsdetaljer + Försenad betalning Betalning genomförd %1$s dagar Hel period @@ -622,6 +632,11 @@ Kommande betalning Giltig till %1$s Betalningshistorik + Lägg till utbetalningsmetod + Direktutbetalning via Faktura + Snabb utbetalning med Swish + Utbetalning via Trustly + Du har inte lagt till någon utbetalningsmetod än. Lägg till en för att vi ska kunna betala ut till dig. Utbetalningskonto Välj utbetalningsmetod Vi behöver tillåtelse att spela in ljud diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index 335c64edc2..62369164f1 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -78,10 +78,8 @@ We use AI to help you as quickly as possible. The answers are generated automatically and may not always be correct.\n\nIf necessary, you’ll be connected to our service team to help you further.\n\nThe details of your insurance are outlined in your insurance letter and terms and conditions. AI and automated messages Scan the QR-code with the BankID app on the phone where it’s installed or log in with email using the button below. - Payout via a Swedish bank account + Payout to a Swedish bank account Bank account - Clearing - Direct payment via Trustly Hej - just nu har vi lunch-stängt i chatten. Vi svarar så fort vi kan när vi öppnar igen klockan 13. Thank you for reaching out to us. We will answer as soon as possible. Hej - just nu är chatten stängd. Vi svarar så fort vi kan när vi öppnar. @@ -147,7 +145,7 @@ Please enter year of construction Year of construction You + %1$d - Change account + Change payout account Change coverage level Talk to a human If it’s not working or if you have any other questions, please let us know and one of our specialists will help out shortly! @@ -608,7 +606,19 @@ If you update your bank account close to the %1$s, the withdrawal might take a few extra days. This payment failed and was added to your payment on %1$s. Payment history + Payment in progress + It may take up to 5 business days for the charge to appear Payment method + Amount due: %1$s + We couldn\'t collect this payment from your bank account. Pay now to keep your coverage active. + Review payment + Pay now to avoid interruption + Payment due + Ensure your account has enough funds to cover this payment + Pay %1$s + Overdue since %1$s + View payment details + Payment overdue Payment successful %1$s days Full period @@ -622,6 +632,11 @@ Upcoming payment Valid until %1$s Payment history + Add payout method + Direct payment via Invoice + Instant payout with Swish + Payout via Trustly + You haven’t added a payout method yet. Add one to receive payouts. Payout account Choose payout method We need permission to record audio diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index 416af9b0a8..d031beede6 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -80,8 +80,6 @@ Skanna QR-koden med BankID-appen på din telefon, eller logga in med din e-postadress nedan. Utbetalning till ett svenskt bankkonto Bankkonto - Clearing - Direktutbetalning via Trustly Hej - just nu har vi lunch-stängt i chatten. Vi svarar så fort vi kan när vi öppnar igen klockan 13. Tack för ditt meddelande. Vi svarar så snart som möjligt. Just nu är chatten stängd. Vi svarar så fort vi kan när vi öppnar. @@ -147,7 +145,7 @@ Ange byggår Byggår Du + %1$d - Ändra konto + Ändra utbetalningskonto Ändra skyddsnivå Chatta med en specialist Om det inte fungerar eller om du har några andra frågor, vänligen meddela oss så hjälper en av våra specialister till inom kort! @@ -517,7 +515,7 @@ Fakturan skickas till din Kivra-inkorg 14 dagar före förfallodatumet varje månad. Tillgänglighet Juridisk information - Juridisk information + Legal information Personuppgifter Fråga angående skadeanmälan - Fordon reg. %1$s Välj land och språk @@ -608,7 +606,19 @@ Om du uppdaterar ditt bankkonto nära den %1$s kan det ta några extra dagar innan pengarna dras. Denna betalning misslyckades och lades till i din betalning den %1$s . Betalningshistorik + Betalning pågår + Det kan ta upp till 5 bankdagar innan betalningen syns Betalningssätt + Att betala: %1$s + Vi kunde inte dra betalningen från ditt bankkonto. Betala för att undvika avbrott i ditt skydd. + Granska betalning + Betala för att undvika avbrott + Förfallodag + Se till att det finns tillräckliga medel på kontot för att genomföra betalningen + Betala %1$s + Försenad sedan %1$s + Visa betalningsdetaljer + Försenad betalning Betalning genomförd %1$s dagar Hel period @@ -622,6 +632,11 @@ Kommande betalning Giltig till %1$s Betalningshistorik + Lägg till utbetalningsmetod + Direktutbetalning via Faktura + Snabb utbetalning med Swish + Utbetalning via Trustly + Du har inte lagt till någon utbetalningsmetod än. Lägg till en för att vi ska kunna betala ut till dig. Utbetalningskonto Välj utbetalningsmetod Vi behöver tillåtelse att spela in ljud diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index f684d45a2e..12f8ea7319 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -78,10 +78,8 @@ We use AI to help you as quickly as possible. The answers are generated automatically and may not always be correct.\n\nIf necessary, you’ll be connected to our service team to help you further.\n\nThe details of your insurance are outlined in your insurance letter and terms and conditions. AI and automated messages Scan the QR-code with the BankID app on the phone where it’s installed or log in with email using the button below. - Payout via a Swedish bank account + Payout to a Swedish bank account Bank account - Clearing - Direct payment via Trustly Hej - just nu har vi lunch-stängt i chatten. Vi svarar så fort vi kan när vi öppnar igen klockan 13. Thank you for reaching out to us. We will answer as soon as possible. Hej - just nu är chatten stängd. Vi svarar så fort vi kan när vi öppnar. @@ -147,7 +145,7 @@ Please enter year of construction Year of construction You + %1$d - Change account + Change payout account Change coverage level Talk to a human If it’s not working or if you have any other questions, please let us know and one of our specialists will help out shortly! @@ -608,7 +606,19 @@ If you update your bank account close to the %1$s, the withdrawal might take a few extra days. This payment failed and was added to your payment on %1$s. Payment history + Payment in progress + It may take up to 5 business days for the charge to appear Payment method + Amount due: %1$s + We couldn't collect this payment from your bank account. Pay now to keep your coverage active. + Review payment + Pay now to avoid interruption + Payment due + Ensure your account has enough funds to cover this payment + Pay %1$s + Overdue since %1$s + View payment details + Payment overdue Payment successful %1$s days Full period @@ -622,6 +632,11 @@ Upcoming payment Valid until %1$s Payment history + Add payout method + Direct payment via Invoice + Instant payout with Swish + Payout via Trustly + You haven’t added a payout method yet. Add one to receive payouts. Payout account Choose payout method We need permission to record audio diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/NavigationBar.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/NavigationBar.kt index 0f69b77270..34bf71df73 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/NavigationBar.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/NavigationBar.kt @@ -77,6 +77,7 @@ fun NavigationBar( onNavigateToDestination: (TopLevelGraph) -> Unit, getIsCurrentlySelected: (TopLevelGraph) -> Boolean, modifier: Modifier = Modifier, + getShowNotificationBadge: (TopLevelGraph) -> Boolean = { false }, ) { val borderColor = NavigationTokens.BorderColor.value NavigationContainer(modifier) { @@ -110,6 +111,7 @@ fun NavigationBar( top = NavigationBarTokens.ItemTopPadding, bottom = NavigationBarTokens.ItemBottomPadding, ), + showNotificationBadge = getShowNotificationBadge(destination), modifier = Modifier.weight(1f) .semantics { role = Role.Tab @@ -128,6 +130,7 @@ fun NavigationRail( getIsCurrentlySelected: (TopLevelGraph) -> Boolean, isExtraTall: Boolean, modifier: Modifier = Modifier, + getShowNotificationBadge: (TopLevelGraph) -> Boolean = { false }, ) { val borderColor = NavigationTokens.BorderColor.value NavigationContainer(modifier.fillMaxHeight()) { @@ -177,6 +180,7 @@ fun NavigationRail( top = NavigationRailTokens.ItemTopPadding, bottom = NavigationRailTokens.ItemBottomPadding, ), + showNotificationBadge = getShowNotificationBadge(destination), modifier = Modifier.semantics { role = Role.Tab this.selected = selected @@ -214,6 +218,7 @@ private fun NavigationItem( onClick: () -> Unit, itemPaddings: PaddingValues, modifier: Modifier = Modifier, + showNotificationBadge: Boolean = false, ) { val interactionSource = remember { MutableInteractionSource() } var itemWidthPx by remember { mutableIntStateOf(0) } @@ -236,7 +241,9 @@ private fun NavigationItem( .padding(itemPaddings), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box { + Box( + modifier = Modifier.notificationCircle(showNotificationBadge), + ) { Icon( imageVector = icon, contentDescription = EmptyContentDescription, diff --git a/app/feature/feature-payments/src/main/graphql/MutationManuallyChargeMember.graphql b/app/feature/feature-payments/src/main/graphql/MutationManuallyChargeMember.graphql new file mode 100644 index 0000000000..8767d2322a --- /dev/null +++ b/app/feature/feature-payments/src/main/graphql/MutationManuallyChargeMember.graphql @@ -0,0 +1,7 @@ +mutation ManuallyChargeMember { + manuallyChargeMember { + userError { + message + } + } +} diff --git a/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql b/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql new file mode 100644 index 0000000000..1cbc3a7bb6 --- /dev/null +++ b/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql @@ -0,0 +1,23 @@ +query ManualChargeInfo { + currentMember { + missedChargeIdToChargeManually + pastCharges { + ...on MemberCharge { + id + date + status + net { + ...MoneyFragment + } + } + } + paymentInformation { + status + chargeMethod { + displayName + descriptor + paymentMethod + } + } + } +} diff --git a/app/feature/feature-payments/src/main/graphql/QueryUpcomingPayment.graphql b/app/feature/feature-payments/src/main/graphql/QueryUpcomingPayment.graphql index c6e01c8d77..af63d30a06 100644 --- a/app/feature/feature-payments/src/main/graphql/QueryUpcomingPayment.graphql +++ b/app/feature/feature-payments/src/main/graphql/QueryUpcomingPayment.graphql @@ -1,5 +1,15 @@ query UpcomingPayment { currentMember { + missedChargeIdToChargeManually + pastCharges { + ...on MemberCharge { + id + status + net { + ...MoneyFragment + } + } + } activeContracts { terminationDueToMissedPayments terminationDate diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt index 84c517f600..81694493c5 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt @@ -3,6 +3,7 @@ package com.hedvig.android.feature.payments import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.feature.payments.data.Discount +import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.MemberCharge import com.hedvig.android.feature.payments.data.MemberChargeShortInfo import com.hedvig.android.feature.payments.data.MemberPaymentChargeMethod @@ -98,6 +99,7 @@ internal val chargeHistoryPreviewData = listOf( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + sum = UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( @@ -131,6 +133,7 @@ internal val chargeHistoryPreviewData = listOf( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( @@ -164,6 +167,7 @@ internal val chargeHistoryPreviewData = listOf( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( @@ -197,6 +201,7 @@ internal val chargeHistoryPreviewData = listOf( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( @@ -230,6 +235,7 @@ internal val chargeHistoryPreviewData = listOf( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( @@ -266,6 +272,7 @@ internal val paymentOverViewPreviewData: PaymentOverview failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + sum = UiMoney(200.0, UiCurrencyCode.SEK), ), ) return PaymentOverview( @@ -274,7 +281,9 @@ internal val paymentOverViewPreviewData: PaymentOverview paymentConnection = PaymentConnection.Active( displayName = "Nordea", displayValue = "31489*****", + chargeMethod = MemberPaymentChargeMethod.TRUSTLY, ), + isManualChargeAllowed = ManualChargeToPrompt(UiMoney(200.0, UiCurrencyCode.SEK)), ) } @@ -287,6 +296,7 @@ internal val paymentDetailsPreviewData = MemberCharge( failedCharge = MemberCharge.FailedCharge( fromDate = LocalDate.fromEpochDays(200), toDate = LocalDate.fromEpochDays(201), + sum = UiMoney(200.0, UiCurrencyCode.SEK), ), chargeBreakdowns = listOf( MemberCharge.ChargeBreakdown( diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt new file mode 100644 index 0000000000..9d0151971c --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt @@ -0,0 +1,69 @@ +package com.hedvig.android.feature.payments.data + +import arrow.core.Either +import arrow.core.raise.context.bind +import arrow.core.raise.context.either +import arrow.core.raise.context.raise +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy.NetworkFirst +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.featureflags.FeatureManager +import com.hedvig.android.featureflags.flags.Feature +import kotlinx.coroutines.flow.first +import kotlinx.datetime.LocalDate +import octopus.ManualChargeInfoQuery +import octopus.type.MemberChargeStatus +import com.hedvig.android.logger.logcat +import octopus.type.MemberPaymentConnectionStatus + +internal interface GetManualChargeInfoUseCase { + suspend fun invoke(): Either +} + +internal class GetManualChargeInfoUseCaseImpl( + private val apolloClient: ApolloClient, +): GetManualChargeInfoUseCase { + override suspend fun invoke(): Either = either { + + val currentMember = apolloClient.query(ManualChargeInfoQuery()) + .fetchPolicy(NetworkFirst) + .safeExecute(::ErrorMessage) + .bind() + .currentMember + + val showManualCharge = currentMember.missedChargeIdToChargeManually + + if (showManualCharge==null) { + logcat {"GetManualChargeInfoUseCaseImpl: missedChargeIdToChargeManually is null"} + raise(ErrorMessage()) + } + + val latestFailedPastCharge = currentMember.pastCharges + .firstOrNull {it.id == showManualCharge} + + if (latestFailedPastCharge==null) { + logcat {"GetManualChargeInfoUseCaseImpl: latestFailedPastCharge is null"} + raise(ErrorMessage()) + } + + ManualChargeInfo( + chargeId = latestFailedPastCharge.id, + missedDueDate = latestFailedPastCharge.date, + amountDue = UiMoney.fromMoneyFragment(latestFailedPastCharge.net), + bankAccountDisplayValue = currentMember.paymentInformation.chargeMethod?.displayName, + bankDescriptor = currentMember.paymentInformation.chargeMethod?.descriptor + ) + } +} + +internal data class ManualChargeInfo( + val chargeId: String?, + val missedDueDate: LocalDate, + val amountDue: UiMoney, + val bankDescriptor: String?, + val bankAccountDisplayValue: String? +) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/MemberCharge.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/MemberCharge.kt index 99fada4411..22625c6236 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/MemberCharge.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/MemberCharge.kt @@ -3,13 +3,9 @@ package com.hedvig.android.feature.payments.data import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.feature.payments.data.Discount.DiscountStatus -import kotlin.String -import kotlin.time.Clock import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone import kotlinx.datetime.daysUntil import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.todayIn import kotlinx.serialization.Serializable import octopus.PaymentHistoryWithDetailsQuery import octopus.ShortPaymentHistoryQuery @@ -47,6 +43,7 @@ internal data class MemberCharge( data class FailedCharge( val fromDate: LocalDate, val toDate: LocalDate, + val sum: UiMoney, ) internal enum class MemberChargeStatus { @@ -170,7 +167,8 @@ internal fun MemberChargeFragment.toMemberCharge( internal fun String?.toChargeMethod(): MemberPaymentChargeMethod { return when { - this?.startsWith("kivra", ignoreCase = true) == true -> MemberPaymentChargeMethod.KIVRA + this?.startsWith("kivra", ignoreCase = true) == true || + this?.startsWith("invoice", ignoreCase = true) == true -> MemberPaymentChargeMethod.KIVRA this?.startsWith("trustly", ignoreCase = true) == true -> MemberPaymentChargeMethod.TRUSTLY else -> MemberPaymentChargeMethod.UNKNOWN } @@ -183,11 +181,20 @@ internal fun MemberChargeFragment.toFailedCharge(): MemberCharge.FailedCharge? { val from = previousChargesPeriods.minOfOrNull { it.fromDate } val to = previousChargesPeriods.maxOfOrNull { it.toDate } + val sum = if (previousChargesPeriods.isNotEmpty()) { + UiMoney( + previousChargesPeriods.sumOf { it.amount.amount }, + UiCurrencyCode.fromCurrencyCode(previousChargesPeriods.first().amount.currencyCode), + ) + } else { + UiMoney(0.0, UiCurrencyCode.SEK) + } return if (from != null && to != null) { MemberCharge.FailedCharge( from, to, + sum, ) } else { null diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentConnection.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentConnection.kt index 36826876a6..11b6e9a56f 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentConnection.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentConnection.kt @@ -6,6 +6,7 @@ internal sealed interface PaymentConnection { data class Active( val displayName: String?, val displayValue: String?, + val chargeMethod: MemberPaymentChargeMethod, ) : PaymentConnection data object Pending : PaymentConnection diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt index ee9fcfb9ed..01804abc00 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt @@ -7,6 +7,7 @@ internal data class PaymentOverview( val memberChargeShortInfo: MemberChargeShortInfo?, val ongoingCharges: List, val paymentConnection: PaymentConnection, + val isManualChargeAllowed: ManualChargeToPrompt?, ) { data class OngoingCharge( val id: String, @@ -15,6 +16,10 @@ internal data class PaymentOverview( ) } +internal data class ManualChargeToPrompt( + val sum: UiMoney, +) + internal data class MemberChargeShortInfo( val netAmount: UiMoney, val dueDate: LocalDate, diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/TriggerManualChargeUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/TriggerManualChargeUseCase.kt new file mode 100644 index 0000000000..0b4e4194ca --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/TriggerManualChargeUseCase.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.feature.payments.data + +import arrow.core.Either +import arrow.core.raise.context.bind +import arrow.core.raise.context.either +import arrow.core.raise.context.raise +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.apollo.ErrorMessage +import kotlinx.datetime.LocalDate +import octopus.ManuallyChargeMemberMutation + +internal interface TriggerManualChargeUseCase { + suspend fun invoke(): Either +} + +internal class TriggerManualChargeUseCaseImpl( + private val apolloClient: ApolloClient +): TriggerManualChargeUseCase { + override suspend fun invoke(): Either = either { + val result = apolloClient + .mutation(ManuallyChargeMemberMutation()) + .safeExecute() + .mapLeft { raise(ErrorMessage()) } + .bind() + + if (result.manuallyChargeMember.userError!=null) raise(ErrorMessage( + result.manuallyChargeMember.userError.message + )) else Unit + } +} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/di/PaymentsModule.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/di/PaymentsModule.kt index 7272dbcefd..f2b93e6f3a 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/di/PaymentsModule.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/di/PaymentsModule.kt @@ -9,10 +9,14 @@ import com.hedvig.android.feature.payments.data.GetDiscountsOverviewUseCase import com.hedvig.android.feature.payments.data.GetDiscountsOverviewUseCaseImpl import com.hedvig.android.feature.payments.data.GetDiscountsUseCase import com.hedvig.android.feature.payments.data.GetDiscountsUseCaseImpl +import com.hedvig.android.feature.payments.data.GetManualChargeInfoUseCase +import com.hedvig.android.feature.payments.data.GetManualChargeInfoUseCaseImpl import com.hedvig.android.feature.payments.data.GetMemberPaymentsDetailsUseCase import com.hedvig.android.feature.payments.data.GetMemberPaymentsDetailsUseCaseImpl import com.hedvig.android.feature.payments.data.GetPaymentsHistoryUseCase import com.hedvig.android.feature.payments.data.GetPaymentsHistoryUseCaseImpl +import com.hedvig.android.feature.payments.data.TriggerManualChargeUseCase +import com.hedvig.android.feature.payments.data.TriggerManualChargeUseCaseImpl import com.hedvig.android.feature.payments.overview.data.GetForeverInformationUseCase import com.hedvig.android.feature.payments.overview.data.GetForeverInformationUseCaseImpl import com.hedvig.android.feature.payments.overview.data.GetUpcomingPaymentUseCase @@ -22,8 +26,10 @@ import com.hedvig.android.feature.payments.overview.data.GetUpcomingPaymentUseCa import com.hedvig.android.feature.payments.ui.details.PaymentDetailsViewModel import com.hedvig.android.feature.payments.ui.discounts.DiscountsViewModel import com.hedvig.android.feature.payments.ui.history.PaymentHistoryViewModel +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeViewModel import com.hedvig.android.feature.payments.ui.memberpaymentdetails.MemberPaymentDetailsViewModel import com.hedvig.android.feature.payments.ui.payments.PaymentsViewModel +import com.hedvig.android.featureflags.FeatureManager import kotlin.time.Clock import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -46,8 +52,9 @@ val paymentsModule = module { } single { GetUpcomingPaymentUseCaseImpl( - get(), - get(), + apolloClient = get(), + clock = get(), + featureManager = get(), ) } single { @@ -109,6 +116,7 @@ val paymentsModule = module { GetUpcomingPaymentUseCaseImpl( get(), clock = get(), + featureManager = get(), ) } single { @@ -116,4 +124,21 @@ val paymentsModule = module { clock = get(), ) } + + single { + TriggerManualChargeUseCaseImpl(get(),) + } + + single { + GetManualChargeInfoUseCaseImpl( + get(), + ) + } + + viewModel { + ManualChargeViewModel( + get(), + get(), + ) + } } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt index 2e4eb9d1a8..5dbdbf7dbb 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt @@ -28,4 +28,11 @@ internal sealed interface PaymentsDestinations { @Serializable data object MemberPaymentDetails : PaymentsDestinations, Destination + + @Serializable + data object ManualCharge: PaymentsDestinations, Destination + + @Serializable + data object ManualChargeSuccess: PaymentsDestinations, Destination } + diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsGraph.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsGraph.kt index f41b217f16..469042d92d 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsGraph.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsGraph.kt @@ -12,6 +12,9 @@ import com.hedvig.android.feature.payments.ui.discounts.DiscountsDestination import com.hedvig.android.feature.payments.ui.discounts.DiscountsViewModel import com.hedvig.android.feature.payments.ui.history.PaymentHistoryDestination import com.hedvig.android.feature.payments.ui.history.PaymentHistoryViewModel +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeDestination +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeSuccessDestination +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeViewModel import com.hedvig.android.feature.payments.ui.memberpaymentdetails.MemberPaymentDetailsDestination import com.hedvig.android.feature.payments.ui.memberpaymentdetails.MemberPaymentDetailsViewModel import com.hedvig.android.feature.payments.ui.payments.PaymentsDestination @@ -20,6 +23,7 @@ import com.hedvig.android.language.LanguageService import com.hedvig.android.navigation.compose.navDeepLinks import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typedPopUpTo import com.hedvig.android.navigation.core.HedvigDeepLinkContainer import com.hedvig.android.shared.foreverui.ui.ui.ForeverDestination import com.hedvig.android.shared.foreverui.ui.ui.ForeverViewModel @@ -57,9 +61,40 @@ fun NavGraphBuilder.paymentsGraph( onMemberPaymentDetailsClicked = dropUnlessResumed { navController.navigate(PaymentsDestinations.MemberPaymentDetails) }, + onOpenManualCharge = { + navController.navigate(PaymentsDestinations.ManualCharge) + }, + ) + } + + navdestination( + deepLinks = navDeepLinks(hedvigDeepLinkContainer.manualCharge), + ) { + val viewModel: ManualChargeViewModel = koinViewModel() + ManualChargeDestination( + viewModel = viewModel, + navigateUp = navController::navigateUp, + onNavigateToPaymentDetails = dropUnlessResumed { chargeId: String -> + navController.navigate( + PaymentsDestinations.Details( + chargeId, + ), + ) + }, + onNavigateToSuccess = { + navController.navigate(PaymentsDestinations.ManualChargeSuccess) { + typedPopUpTo { + inclusive = true + } + } + } ) } + navdestination{ + ManualChargeSuccessDestination(navController::navigateUp) + } + navdestination { val viewModel: PaymentDetailsViewModel = koinViewModel(parameters = { parametersOf(this.memberChargeId) }) PaymentDetailsDestination( diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt index 78f9bc53b8..8c888d24ad 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt @@ -7,18 +7,33 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.cache.normalized.FetchPolicy import com.apollographql.apollo.cache.normalized.fetchPolicy import com.hedvig.android.apollo.ErrorMessage -import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.apollo.safeFlow import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.MemberCharge import com.hedvig.android.feature.payments.data.MemberChargeShortInfo +import com.hedvig.android.feature.payments.data.MemberPaymentChargeMethod import com.hedvig.android.feature.payments.data.PaymentConnection +import com.hedvig.android.feature.payments.data.PaymentConnection.Active import com.hedvig.android.feature.payments.data.PaymentOverview import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge +import com.hedvig.android.feature.payments.data.toChargeMethod import com.hedvig.android.feature.payments.data.toFailedCharge +import com.hedvig.android.featureflags.FeatureManager +import com.hedvig.android.logger.logcat import kotlin.time.Clock import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import octopus.UpcomingPaymentQuery @@ -27,56 +42,89 @@ import octopus.type.MemberChargeStatus import octopus.type.MemberPaymentConnectionStatus internal interface GetUpcomingPaymentUseCase { - suspend fun invoke(): Either + suspend fun invoke(): Flow> } internal data class GetUpcomingPaymentUseCaseImpl( val apolloClient: ApolloClient, + val featureManager: FeatureManager, val clock: Clock, ) : GetUpcomingPaymentUseCase { - override suspend fun invoke(): Either = either { - val result = apolloClient.query(UpcomingPaymentQuery()) - .fetchPolicy(FetchPolicy.NetworkFirst) - .safeExecute(::ErrorMessage) - .bind() + override suspend fun invoke(): Flow> { + return flow { + while (currentCoroutineContext().isActive) { + emitAll( + apolloClient.query(UpcomingPaymentQuery()) + .fetchPolicy(FetchPolicy.NetworkFirst) + .safeFlow { + logcat { "GetUpcomingPaymentUseCaseImpl error: $it" } + ErrorMessage() + } + .map { response -> + either { + val result = response.bind() + val paymentConnection = run { + val paymentInformation = result.currentMember.paymentInformation + when (paymentInformation.status) { + MemberPaymentConnectionStatus.ACTIVE -> { + PaymentConnection.Active( + displayName = paymentInformation.chargeMethod?.displayName, + displayValue = paymentInformation.chargeMethod?.descriptor, + chargeMethod = paymentInformation.chargeMethod?.paymentMethod.toChargeMethod(), + ) + } - PaymentOverview( - memberChargeShortInfo = result.currentMember.futureCharge?.toMemberChargeShortInfo(), - ongoingCharges = result.currentMember.ongoingCharges.mapNotNull { - val id = it.id ?: return@mapNotNull null - OngoingCharge(id, it.date, UiMoney.fromMoneyFragment(it.net)) - }, - paymentConnection = run { - val paymentInformation = result.currentMember.paymentInformation - when (paymentInformation.status) { - MemberPaymentConnectionStatus.ACTIVE -> { - PaymentConnection.Active( - displayName = paymentInformation.chargeMethod?.displayName, - displayValue = paymentInformation.chargeMethod?.descriptor, - ) - } + MemberPaymentConnectionStatus.PENDING -> { + PaymentConnection.Pending + } - MemberPaymentConnectionStatus.PENDING -> { - PaymentConnection.Pending - } + MemberPaymentConnectionStatus.NEEDS_SETUP -> { + val firstKnownTerminationDateForContractTerminatedDueToMissedPayments = result + .currentMember + .activeContracts + .filter { it.terminationDueToMissedPayments } + .mapNotNull { it.terminationDate } + .sorted() + .firstOrNull() + PaymentConnection.NeedsSetup(firstKnownTerminationDateForContractTerminatedDueToMissedPayments) + } - MemberPaymentConnectionStatus.NEEDS_SETUP -> { - val firstKnownTerminationDateForContractTerminatedDueToMissedPayments = result - .currentMember - .activeContracts - .filter { it.terminationDueToMissedPayments } - .mapNotNull { it.terminationDate } - .sorted() - .firstOrNull() - PaymentConnection.NeedsSetup(firstKnownTerminationDateForContractTerminatedDueToMissedPayments) - } + MemberPaymentConnectionStatus.UNKNOWN__ -> { + PaymentConnection.Unknown + } + } + } + val memberChargeShortInfo = result.currentMember.futureCharge?.toMemberChargeShortInfo() - MemberPaymentConnectionStatus.UNKNOWN__ -> { - PaymentConnection.Unknown - } - } - }, - ) + val missedChargeIdToChargeManually: String? = result.currentMember.missedChargeIdToChargeManually + + val isManualChargeAllowed = if (missedChargeIdToChargeManually!=null) { + val failedChargeNet = result.currentMember.pastCharges.firstOrNull { + it.id == missedChargeIdToChargeManually}?.net?.let { net -> + UiMoney.fromMoneyFragment(net) + } + if (failedChargeNet!=null) { + ManualChargeToPrompt(failedChargeNet) + } else null + } else { + null + } + + PaymentOverview( + memberChargeShortInfo = memberChargeShortInfo, + ongoingCharges = result.currentMember.ongoingCharges.mapNotNull { + val id = it.id ?: return@mapNotNull null + OngoingCharge(id, it.date, UiMoney.fromMoneyFragment(it.net)) + }, + isManualChargeAllowed = isManualChargeAllowed, + paymentConnection = paymentConnection, + ) + } + }, + ) + delay(3.seconds) + } + } } } @@ -97,17 +145,20 @@ private fun MemberChargeFragment.toMemberChargeShortInfo() = MemberChargeShortIn internal class GetUpcomingPaymentUseCaseDemo( private val clock: Clock, ) : GetUpcomingPaymentUseCase { - override suspend fun invoke(): Either { - return PaymentOverview( - MemberChargeShortInfo( - netAmount = UiMoney(100.0, UiCurrencyCode.SEK), - id = "id", - status = MemberCharge.MemberChargeStatus.SUCCESS, - dueDate = (clock.now() + 10.days).toLocalDateTime(TimeZone.UTC).date, - failedCharge = null, - ), - emptyList(), - PaymentConnection.Unknown, - ).right() + override suspend fun invoke(): Flow> { + return flowOf( + PaymentOverview( + MemberChargeShortInfo( + netAmount = UiMoney(100.0, UiCurrencyCode.SEK), + id = "id", + status = MemberCharge.MemberChargeStatus.SUCCESS, + dueDate = (clock.now() + 10.days).toLocalDateTime(TimeZone.UTC).date, + failedCharge = null, + ), + emptyList(), + PaymentConnection.Unknown, + isManualChargeAllowed = null, + ).right(), + ) } } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt new file mode 100644 index 0000000000..6c0041e3f4 --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt @@ -0,0 +1,350 @@ +package com.hedvig.android.feature.payments.ui.manualcharge + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiCurrencyCode +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalDivider +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.WarningFilled +import com.hedvig.android.design.system.hedvig.rememberHedvigDateTimeFormatter +import com.hedvig.android.design.system.hedvig.rememberHedvigMonthDateTimeFormatter +import com.hedvig.android.feature.payments.data.ManualChargeInfo +import hedvig.resources.GENERAL_ERROR_BODY +import hedvig.resources.GENERAL_RETRY +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_BODY +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_DUE_DATE +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_FINE_PRINT +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_PAY +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_SINCE +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_VIEW_DETAILS +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_TITLE +import hedvig.resources.Res +import hedvig.resources.general_close_button +import hedvig.resources.payment_details_receipt_card_total +import kotlinx.datetime.LocalDate +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun ManualChargeDestination( + viewModel: ManualChargeViewModel, + navigateUp: () -> Unit, + onNavigateToPaymentDetails: (chargeId: String) -> Unit, + onNavigateToSuccess: () -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + ManualChargeScreen( + uiState = uiState.value, + navigateUp = navigateUp, + reload = { viewModel.emit(ManualChargeEvent.Retry) }, + onNavigateToPaymentDetails = onNavigateToPaymentDetails, + onNavigateToSuccess = { + viewModel.emit(ManualChargeEvent.ClearNav) + onNavigateToSuccess() + }, + onTriggerPayment = { + viewModel.emit(ManualChargeEvent.TriggerCharge) } + ) +} + +@Composable +private fun ManualChargeScreen( + uiState: ManualChargeUiState, + navigateUp: () -> Unit, + reload: () -> Unit, + onNavigateToPaymentDetails: (chargeId: String) -> Unit, + onNavigateToSuccess: () -> Unit, + onTriggerPayment: () -> Unit +) { + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_TITLE), + ) { + when (uiState) { + + is ManualChargeUiState.Failure -> { + val subTitle = if (uiState.error.message!=null) uiState.error.message else + stringResource(Res.string.GENERAL_ERROR_BODY) + val buttonText = if (uiState.error.message!=null) stringResource(Res.string.general_close_button) else + stringResource(Res.string.GENERAL_RETRY) + val onButtonClick = if (uiState.error.message!=null) navigateUp else reload + + HedvigErrorSection( + onButtonClick = onButtonClick, + Modifier.weight(1f).fillMaxWidth(), + subTitle = subTitle, + buttonText = buttonText + ) + + } + + ManualChargeUiState.Loading -> { + HedvigFullScreenCenterAlignedProgress( + modifier = Modifier.weight(1f), + ) + } + + is ManualChargeUiState.Success -> { + if (uiState.navigateToSuccess!=null) { + LaunchedEffect(uiState.navigateToSuccess) { + onNavigateToSuccess() + } + } else { + ManualChargeSuccessScreen( + uiState, + onNavigateToPaymentDetails = onNavigateToPaymentDetails, + onTriggerPayment = onTriggerPayment + ) + } + } + } + } +} + +@Composable +private fun ManualChargeSuccessScreen( + uiState: ManualChargeUiState.Success, + onNavigateToPaymentDetails: (chargeId: String) -> Unit, + onTriggerPayment: () -> Unit, +) { + val dateTimeFormatter = rememberHedvigMonthDateTimeFormatter() + val dateTimeFormatterWithYear = rememberHedvigDateTimeFormatter() + Column( + modifier = Modifier + .padding( + top = 8.dp, + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + .hedvigDropShadow(HedvigTheme.shapes.cornerXLarge) + .fillMaxWidth() + .background( + color = HedvigTheme.colorScheme.backgroundPrimary, + shape = HedvigTheme.shapes.cornerXLarge, + ) + .border( + width = 1.dp, + color = HedvigTheme.colorScheme.borderPrimary, + shape = HedvigTheme.shapes.cornerXLarge, + ) + + .clip(HedvigTheme.shapes.cornerXLarge) + .padding(16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = HedvigIcons.WarningFilled, + contentDescription = null, + tint = HedvigTheme.colorScheme.signalRedElement, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + HedvigText( + text = stringResource( + Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_SINCE, + dateTimeFormatter.format(uiState.manualChargeInfo.missedDueDate), + ), + ) + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_BODY), + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + if (uiState.manualChargeInfo.chargeId!=null) { + HedvigButton( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_VIEW_DETAILS), + onClick = { + onNavigateToPaymentDetails(uiState.manualChargeInfo.chargeId) + }, + enabled = true, + modifier = Modifier.fillMaxWidth(), + buttonStyle = ButtonDefaults.ButtonStyle.Ghost, + buttonSize = ButtonDefaults.ButtonSize.Medium, + border = HedvigTheme.colorScheme.borderPrimary, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_DUE_DATE), + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + HedvigText( + text = dateTimeFormatterWithYear.format(uiState.manualChargeInfo.missedDueDate), + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + } + if (uiState.manualChargeInfo.bankDescriptor!=null && + uiState.manualChargeInfo.bankAccountDisplayValue!=null) { + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + HedvigText( + text = uiState.manualChargeInfo.bankDescriptor, + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + HedvigText( + text = uiState.manualChargeInfo.bankAccountDisplayValue, + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = stringResource(Res.string.payment_details_receipt_card_total), + ) + HedvigText( + text = uiState.manualChargeInfo.amountDue.toString(), + textAlign = TextAlign.End, + ) + } + + HedvigButton( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_PAY, uiState.manualChargeInfo.amountDue), + onClick = onTriggerPayment, + enabled = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_FINE_PRINT), + color = HedvigTheme.colorScheme.textSecondaryTranslucent, + textAlign = TextAlign.Center, + style = HedvigTheme.typography.label, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + +} + +@Composable +@Preview +@HedvigPreview +private fun ManualChargeScreenSuccessPreview() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ManualChargeScreen( + uiState = ManualChargeUiState.Success( + ManualChargeInfo( + missedDueDate = LocalDate(2026, 1, 15), + amountDue = UiMoney(100.0, UiCurrencyCode.SEK), + chargeId = "chargeId", + bankDescriptor = "Bank account", + bankAccountDisplayValue = "**** 8324" + ), + navigateToSuccess = null + ), + navigateUp = {}, + reload = {}, + {}, + {}, + {}, + ) + } + } +} + +@Composable +@Preview +@HedvigPreview +private fun ManualChargeScreenLoadingPreview() { + HedvigTheme { + Surface { + ManualChargeScreen( + uiState = ManualChargeUiState.Loading, + navigateUp = {}, + reload = {}, + {}, + {}, + {}, + ) + } + } +} + +@Composable +@Preview +@HedvigPreview +private fun ManualChargeScreenFailurePreview() { + HedvigTheme { + Surface { + ManualChargeScreen( + uiState = ManualChargeUiState.Failure(ErrorMessage("Payment method not allowed")), + navigateUp = {}, + reload = {}, + {}, + {}, + {}, + ) + } + } +} + + diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeSuccessDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeSuccessDestination.kt new file mode 100644 index 0000000000..05ad0426c0 --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeSuccessDestination.kt @@ -0,0 +1,69 @@ +package com.hedvig.android.feature.payments.ui.manualcharge + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large +import com.hedvig.android.design.system.hedvig.EmptyState +import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateButtonStyle.NoButton +import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateIconStyle.SUCCESS +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import hedvig.resources.PAYMENTS_PAYMENT_IN_PROGRESS +import hedvig.resources.PAYMENTS_PAYMENT_IN_PROGRESS_DESCRIPTION +import hedvig.resources.Res +import hedvig.resources.general_close_button +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun ManualChargeSuccessDestination(popBackStack: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + + WindowInsetsSides.Bottom, + ), + ), + ) { + Spacer(Modifier.weight(1f)) + EmptyState( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.PAYMENTS_PAYMENT_IN_PROGRESS), + description = stringResource( + Res.string.PAYMENTS_PAYMENT_IN_PROGRESS_DESCRIPTION, + ), + iconStyle = SUCCESS, + buttonStyle = NoButton, + ) + Spacer(Modifier.weight(1f)) + HedvigTextButton( + stringResource(Res.string.general_close_button), + onClick = dropUnlessResumed { popBackStack() }, + buttonSize = Large, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@HedvigPreview +@Composable +private fun ManualChargeSuccessDestinationPreview() { + ManualChargeSuccessDestination({}) +} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt new file mode 100644 index 0000000000..1d2244377b --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt @@ -0,0 +1,99 @@ +package com.hedvig.android.feature.payments.ui.manualcharge + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.feature.payments.data.GetManualChargeInfoUseCase +import com.hedvig.android.feature.payments.data.ManualChargeInfo +import com.hedvig.android.feature.payments.data.TriggerManualChargeUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import kotlinx.datetime.LocalDate + +internal class ManualChargeViewModel( + getManualChargeInfoUseCase: GetManualChargeInfoUseCase, + triggerManualCharge: TriggerManualChargeUseCase +) : MoleculeViewModel( + initialState = ManualChargeUiState.Loading, + presenter = ManualChargePresenter(getManualChargeInfoUseCase, triggerManualCharge), +) + +private class ManualChargePresenter( + private val getManualChargeInfoUseCase: GetManualChargeInfoUseCase, + private val triggerManualCharge: TriggerManualChargeUseCase +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: ManualChargeUiState, + ): ManualChargeUiState { + var dataLoadIteration by remember { mutableIntStateOf(0) } + var screenState by remember { mutableStateOf(lastState) } + var triggerChargeIteration by remember { mutableIntStateOf(0) } + + CollectEvents { + when (it) { + ManualChargeEvent.Retry -> dataLoadIteration++ + ManualChargeEvent.TriggerCharge -> triggerChargeIteration++ + ManualChargeEvent.ClearNav -> { + val currentState = screenState as? ManualChargeUiState.Success ?: return@CollectEvents + screenState = currentState.copy(navigateToSuccess = null) + } + } + } + + LaunchedEffect(triggerChargeIteration) { + if (triggerChargeIteration>0) { + val currentState = screenState as? ManualChargeUiState.Success ?: return@LaunchedEffect + triggerManualCharge.invoke().fold( + ifLeft = { + screenState = ManualChargeUiState.Failure(it) + }, + ifRight = { + screenState = ManualChargeUiState.Success(currentState.manualChargeInfo, Unit) + } + ) + } + } + + LaunchedEffect(dataLoadIteration) { + screenState = ManualChargeUiState.Loading + getManualChargeInfoUseCase.invoke().fold( + ifRight = { manualChargeInfo -> + screenState = ManualChargeUiState.Success(manualChargeInfo, null) + }, + ifLeft = { failure -> + screenState = ManualChargeUiState.Failure(failure) + }, + ) + } + return screenState + } +} + +internal sealed interface ManualChargeUiState { + data object Loading : ManualChargeUiState + + data class Failure( + val error: ErrorMessage + ) : ManualChargeUiState + + data class Success( + val manualChargeInfo: ManualChargeInfo, + val navigateToSuccess: Unit? + ) : ManualChargeUiState +} + +internal sealed interface ManualChargeEvent { + data object Retry : ManualChargeEvent + + data object TriggerCharge : ManualChargeEvent + data object ClearNav : ManualChargeEvent +} + diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt index dd2a405127..5791961bc6 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt @@ -115,9 +115,11 @@ private fun MemberPaymentDetailsSuccessScreen( onChangeBankAccount: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState())) { + Column( + modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { val explanationBottomSheetState = rememberHedvigBottomSheetState() ExplanationBottomSheet(explanationBottomSheetState) HorizontalItemsWithMaximumSpaceTaken( diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt index 9e3745cc89..4ec92c9e8d 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt @@ -3,6 +3,8 @@ package com.hedvig.android.feature.payments.ui.payments import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.expandVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,12 +26,14 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -39,6 +43,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.core.common.safeCast +import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiCurrencyCode.SEK import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.design.system.hedvig.HedvigCard @@ -51,19 +56,23 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalDivider import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.NotificationDefaults import com.hedvig.android.design.system.hedvig.NotificationDefaults.InfoCardStyle.Button import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Info import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.Campaign import com.hedvig.android.design.system.hedvig.icon.Card import com.hedvig.android.design.system.hedvig.icon.ChevronRight import com.hedvig.android.design.system.hedvig.icon.Clock import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.WarningFilled import com.hedvig.android.design.system.hedvig.placeholder.hedvigPlaceholder import com.hedvig.android.design.system.hedvig.placeholder.shimmer import com.hedvig.android.design.system.hedvig.rememberHedvigDateTimeFormatter import com.hedvig.android.design.system.hedvig.rememberHedvigMonthDateTimeFormatter +import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge import com.hedvig.android.feature.payments.ui.payments.PaymentsEvent.Retry import com.hedvig.android.feature.payments.ui.payments.PaymentsUiState.Content @@ -88,6 +97,10 @@ import hedvig.resources.PAYMENTS_MISSED_PAYMENT import hedvig.resources.PAYMENTS_NO_PAYMENTS_IN_PROGRESS import hedvig.resources.PAYMENTS_PAYMENT_DETAILS_INFO_TITLE import hedvig.resources.PAYMENTS_PAYMENT_HISTORY_BUTTON_LABEL +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_AMOUNT_DUE +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_BODY +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_BUTTON +import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_TITLE import hedvig.resources.PAYMENTS_PROCESSING_PAYMENT import hedvig.resources.PAYMENTS_UPCOMING_PAYMENT import hedvig.resources.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_TITLE @@ -111,6 +124,7 @@ internal fun PaymentsDestination( onPaymentHistoryClicked: () -> Unit, onMemberPaymentDetailsClicked: () -> Unit, onChangeBankAccount: () -> Unit, + onOpenManualCharge: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() PaymentsScreen( @@ -121,6 +135,7 @@ internal fun PaymentsDestination( onPaymentHistoryClicked = onPaymentHistoryClicked, onRetry = { viewModel.emit(Retry) }, onPaymentDetailsClicked = onMemberPaymentDetailsClicked, + onOpenManualCharge = onOpenManualCharge, ) } @@ -132,6 +147,7 @@ private fun PaymentsScreen( onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPaymentDetailsClicked: () -> Unit, + onOpenManualCharge: () -> Unit, onRetry: () -> Unit, ) { val density = LocalDensity.current @@ -193,6 +209,7 @@ private fun PaymentsScreen( onDiscountClicked = onDiscountClicked, onPaymentHistoryClicked = onPaymentHistoryClicked, onPaymentDetailsClicked = onPaymentDetailsClicked, + onOpenManualCharge = onOpenManualCharge, ) Spacer(Modifier.height(16.dp)) } @@ -217,6 +234,7 @@ private fun PaymentsContent( onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPaymentDetailsClicked: () -> Unit, + onOpenManualCharge: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -225,6 +243,20 @@ private fun PaymentsContent( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(8.dp)) + when (val upcomingPaymentInfo = (uiState as? Content)?.upcomingPaymentInfo) { + is PaymentFailed -> { + if (upcomingPaymentInfo.isManualChargeAllowed != null) { + FailedPaymentInfo( + amountDue = upcomingPaymentInfo.isManualChargeAllowed.sum.toString(), + onReviewPaymentClick = onOpenManualCharge, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(Modifier.height(8.dp)) + } + } + + else -> {} + } val ongoingCharges = (uiState as? Content)?.ongoingCharges if (!ongoingCharges.isNullOrEmpty()) { OngoingPaymentCards( @@ -333,7 +365,10 @@ private fun CardNotConnectedWarningCard( } @Composable -private fun UpcomingPaymentInfoCard(upcomingPaymentInfo: UpcomingPaymentInfo?, modifier: Modifier = Modifier) { +private fun UpcomingPaymentInfoCard( + upcomingPaymentInfo: UpcomingPaymentInfo?, + modifier: Modifier = Modifier, +) { Box(modifier) { when (upcomingPaymentInfo) { NoInfo -> {} @@ -347,14 +382,19 @@ private fun UpcomingPaymentInfoCard(upcomingPaymentInfo: UpcomingPaymentInfo?, m is PaymentFailed -> { val monthDateFormatter = rememberHedvigMonthDateTimeFormatter() - HedvigNotificationCard( - priority = NotificationPriority.Attention, - message = stringResource( - Res.string.PAYMENTS_MISSED_PAYMENT, - monthDateFormatter.format(upcomingPaymentInfo.failedPaymentStartDate), - monthDateFormatter.format(upcomingPaymentInfo.failedPaymentEndDate), - ), - ) + if (upcomingPaymentInfo.isManualChargeAllowed == null) { + Column { + HedvigNotificationCard( + priority = NotificationPriority.Attention, + message = stringResource( + Res.string.PAYMENTS_MISSED_PAYMENT, + monthDateFormatter.format(upcomingPaymentInfo.failedPaymentStartDate), + monthDateFormatter.format(upcomingPaymentInfo.failedPaymentEndDate), + ), + style = NotificationDefaults.InfoCardStyle.Default, + ) + } + } } null -> {} @@ -546,6 +586,84 @@ private fun PaymentCard( } } +@Composable +private fun FailedPaymentInfo(amountDue: String, onReviewPaymentClick: () -> Unit, modifier: Modifier = Modifier) { + HedvigCard( + color = HedvigTheme.colorScheme.fillNegative, + modifier = modifier + .fillMaxWidth() + .border(1.dp, HedvigTheme.colorScheme.borderPrimary, + HedvigTheme.shapes.cornerXLarge) + .hedvigDropShadow() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .background( + color = HedvigTheme.colorScheme.signalRedFill, + shape = CircleShape, + ) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = HedvigIcons.WarningFilled, + contentDescription = null, + tint = HedvigTheme.colorScheme.signalRedElement, + modifier = Modifier.size(24.dp), + ) + } + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_TITLE), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textPrimary, + ) + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_AMOUNT_DUE, amountDue), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + Spacer(Modifier.height(8.dp)) + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_BODY), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(Modifier.height(12.dp)) + HedvigButton( + text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_BUTTON), + onClick = onReviewPaymentClick, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + buttonSize = ButtonDefaults.ButtonSize.Small, + ) + } + } +} + @Composable private fun PaymentsListItem( text: String, @@ -569,6 +687,20 @@ private fun PaymentsListItem( ) } +@Composable +@HedvigPreview +private fun PreviewFailedPaymentInfo() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + FailedPaymentInfo( + amountDue = "233 kr", + {}, + ) + } + } +} + + @Composable @HedvigPreview private fun PreviewPaymentScreen( @@ -584,6 +716,7 @@ private fun PreviewPaymentScreen( {}, {}, {}, + {}, ) } } @@ -648,6 +781,9 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = PaymentFailed( System.now().toLocalDateTime(TimeZone.UTC).date, System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + isManualChargeAllowed = ManualChargeToPrompt( + UiMoney(200.0, UiCurrencyCode.SEK), + ), ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active( @@ -677,7 +813,13 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< System.now().toLocalDateTime(TimeZone.UTC).date, "qrdfgeth", ), - upcomingPaymentInfo = NoInfo, + upcomingPaymentInfo = PaymentFailed( + System.now().toLocalDateTime(TimeZone.UTC).date, + System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + isManualChargeAllowed = ManualChargeToPrompt( + UiMoney(200.0, UiCurrencyCode.SEK), + ), + ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsSetup( null, @@ -710,6 +852,9 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = PaymentFailed( System.now().toLocalDateTime(TimeZone.UTC).date, System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + isManualChargeAllowed = ManualChargeToPrompt( + UiMoney(200.0, UiCurrencyCode.SEK), + ), ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsSetup( @@ -728,6 +873,9 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = PaymentFailed( System.now().toLocalDateTime(TimeZone.UTC).date, System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + isManualChargeAllowed = ManualChargeToPrompt( + UiMoney(200.0, UiCurrencyCode.SEK), + ), ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsSetup( diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt index 18c59e81aa..a1f8b673e5 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt @@ -9,7 +9,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.demomode.Provider import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.MemberCharge +import com.hedvig.android.feature.payments.data.MemberPaymentChargeMethod import com.hedvig.android.feature.payments.data.PaymentConnection.Active import com.hedvig.android.feature.payments.data.PaymentConnection.NeedsSetup import com.hedvig.android.feature.payments.data.PaymentConnection.Pending @@ -18,6 +20,7 @@ import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge import com.hedvig.android.feature.payments.overview.data.GetUpcomingPaymentUseCase import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope +import kotlinx.coroutines.flow.collectLatest import kotlinx.datetime.LocalDate internal class PaymentsPresenter( @@ -45,59 +48,63 @@ internal class PaymentsPresenter( PaymentsUiState.Loading } } - getUpcomingPaymentUseCase.provide().invoke().fold( - ifLeft = { - paymentsUiState = PaymentsUiState.Error - }, - ifRight = { paymentOverview -> - paymentsUiState = PaymentsUiState.Content( - isRetrying = false, - upcomingPayment = paymentOverview.memberChargeShortInfo?.let { memberCharge -> - PaymentsUiState.Content.UpcomingPayment.Content( - netAmount = memberCharge.netAmount, - dueDate = memberCharge.dueDate, - id = memberCharge.id, - ) - } ?: PaymentsUiState.Content.UpcomingPayment.NoUpcomingPayment, - upcomingPaymentInfo = run { - val memberCharge = paymentOverview.memberChargeShortInfo - if (memberCharge?.status == MemberCharge.MemberChargeStatus.PENDING) { - return@run PaymentsUiState.Content.UpcomingPaymentInfo.InProgress - } - memberCharge?.failedCharge?.let { failedCharge -> - return@run PaymentsUiState.Content.UpcomingPaymentInfo.PaymentFailed( - failedPaymentStartDate = failedCharge.fromDate, - failedPaymentEndDate = failedCharge.toDate, - ) - } - PaymentsUiState.Content.UpcomingPaymentInfo.NoInfo + getUpcomingPaymentUseCase.provide().invoke() + .collectLatest { result -> + result.fold( + ifLeft = { + paymentsUiState = PaymentsUiState.Error }, - ongoingCharges = paymentOverview.ongoingCharges, - connectedPaymentInfo = when (val paymentConnection = paymentOverview.paymentConnection) { - is Active -> { - PaymentsUiState.Content.ConnectedPaymentInfo.Active( - displayName = paymentConnection.displayName, - maskedAccountNumber = paymentConnection.displayValue, - ) - } - - Pending -> { - PaymentsUiState.Content.ConnectedPaymentInfo.Pending - } - - is NeedsSetup -> { - PaymentsUiState.Content.ConnectedPaymentInfo.NeedsSetup( - dueDateToConnect = paymentConnection.terminationDateIfNotConnected, - ) - } - - Unknown -> { - PaymentsUiState.Content.ConnectedPaymentInfo.Unknown - } + ifRight = { paymentOverview -> + paymentsUiState = PaymentsUiState.Content( + isRetrying = false, + upcomingPayment = paymentOverview.memberChargeShortInfo?.let { memberCharge -> + PaymentsUiState.Content.UpcomingPayment.Content( + netAmount = memberCharge.netAmount, + dueDate = memberCharge.dueDate, + id = memberCharge.id, + ) + } ?: PaymentsUiState.Content.UpcomingPayment.NoUpcomingPayment, + upcomingPaymentInfo = run { + val memberCharge = paymentOverview.memberChargeShortInfo + if (memberCharge?.status == MemberCharge.MemberChargeStatus.PENDING) { + return@run PaymentsUiState.Content.UpcomingPaymentInfo.InProgress + } + memberCharge?.failedCharge?.let { failedCharge -> + return@run PaymentsUiState.Content.UpcomingPaymentInfo.PaymentFailed( + failedPaymentStartDate = failedCharge.fromDate, + failedPaymentEndDate = failedCharge.toDate, + isManualChargeAllowed = paymentOverview.isManualChargeAllowed, + ) + } + PaymentsUiState.Content.UpcomingPaymentInfo.NoInfo + }, + ongoingCharges = paymentOverview.ongoingCharges, + connectedPaymentInfo = when (val paymentConnection = paymentOverview.paymentConnection) { + is Active -> { + PaymentsUiState.Content.ConnectedPaymentInfo.Active( + displayName = paymentConnection.displayName, + maskedAccountNumber = paymentConnection.displayValue, + ) + } + + Pending -> { + PaymentsUiState.Content.ConnectedPaymentInfo.Pending + } + + is NeedsSetup -> { + PaymentsUiState.Content.ConnectedPaymentInfo.NeedsSetup( + dueDateToConnect = paymentConnection.terminationDateIfNotConnected, + ) + } + + Unknown -> { + PaymentsUiState.Content.ConnectedPaymentInfo.Unknown + } + }, + ) }, ) - }, - ) + } } return paymentsUiState } @@ -137,6 +144,7 @@ internal sealed interface PaymentsUiState { data class PaymentFailed( val failedPaymentStartDate: LocalDate, val failedPaymentEndDate: LocalDate, + val isManualChargeAllowed: ManualChargeToPrompt?, ) : UpcomingPaymentInfo } diff --git a/app/featureflags/feature-flags-public/src/main/kotlin/com/hedvig/android/featureflags/flags/Feature.kt b/app/featureflags/feature-flags-public/src/main/kotlin/com/hedvig/android/featureflags/flags/Feature.kt index c50489b0e0..2db61ba0e0 100644 --- a/app/featureflags/feature-flags-public/src/main/kotlin/com/hedvig/android/featureflags/flags/Feature.kt +++ b/app/featureflags/feature-flags-public/src/main/kotlin/com/hedvig/android/featureflags/flags/Feature.kt @@ -21,5 +21,5 @@ enum class Feature( "When enabled, it allows the chat to show media in inline video players in the chat messages", ), DISABLE_REDEEM_CAMPAIGN("Disables the ability to redeem a campaign code"), - ENABLE_CLAIM_HISTORY("Disables the ability to redeem a campaign code"), + ENABLE_CLAIM_HISTORY("Enables claim history"), } diff --git a/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt b/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt index 650003a628..6bf0b02b73 100644 --- a/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt +++ b/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt @@ -79,6 +79,8 @@ interface HedvigDeepLinkContainer { val petIdWithoutContractId: List val petIdWithContractId: List + + val manualCharge: List } internal class HedvigDeepLinkContainerImpl( @@ -196,6 +198,10 @@ internal class HedvigDeepLinkContainerImpl( override val petIdWithContractId: List = baseDeepLinkDomains.map { baseDeepLinkDomain -> "$baseDeepLinkDomain/pet-id?contractId={contractId}" } + + override val manualCharge: List = baseDeepLinkDomains.map { baseDeepLinkDomain -> + "$baseDeepLinkDomain/manual-charge" //todo: check with other platforms + } } val HedvigDeepLinkContainer.allDeepLinkUriPatterns: List @@ -235,4 +241,5 @@ val HedvigDeepLinkContainer.allDeepLinkUriPatterns: List travelAddonWithContractId.first(), petIdWithoutContractId.first(), petIdWithContractId.first(), + manualCharge.first() ) diff --git a/app/notification-badge-data/notification-badge-data-public/src/main/graphql/QueryMissedPayment.graphql b/app/notification-badge-data/notification-badge-data-public/src/main/graphql/QueryMissedPayment.graphql new file mode 100644 index 0000000000..55500cd650 --- /dev/null +++ b/app/notification-badge-data/notification-badge-data-public/src/main/graphql/QueryMissedPayment.graphql @@ -0,0 +1,5 @@ +query MissedPayment { + currentMember { + missedChargeIdToChargeManually + } +} diff --git a/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/di/NotificationBadgeModule.kt b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/di/NotificationBadgeModule.kt index 929a5eb692..3ff74b26cf 100644 --- a/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/di/NotificationBadgeModule.kt +++ b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/di/NotificationBadgeModule.kt @@ -10,6 +10,11 @@ import com.hedvig.android.notification.badge.data.crosssell.GetCrossSellRecommen import com.hedvig.android.notification.badge.data.crosssell.home.CrossSellHomeNotificationServiceImpl import com.hedvig.android.notification.badge.data.crosssell.home.CrossSellHomeNotificationServiceProvider import com.hedvig.android.notification.badge.data.crosssell.home.DemoCrossSellHomeNotificationService +import com.hedvig.android.notification.badge.data.payment.DemoMissedPaymentNotificationService +import com.hedvig.android.notification.badge.data.payment.GetIfMissedPaymentUseCase +import com.hedvig.android.notification.badge.data.payment.GetIfMissedPaymentUseCaseImpl +import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationServiceImpl +import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationServiceProvider import com.hedvig.android.notification.badge.data.storage.DatastoreNotificationBadgeStorage import com.hedvig.android.notification.badge.data.storage.NotificationBadgeStorage import org.koin.dsl.module @@ -33,4 +38,18 @@ val notificationBadgeModule = module { single { CrossSellHomeNotificationServiceImpl(get(), get>()) } + + single { + MissedPaymentNotificationServiceProvider( + demoManager = get(), + demoImpl = DemoMissedPaymentNotificationService(), + prodImpl = get(), + ) + } + single { + GetIfMissedPaymentUseCaseImpl(get()) + } + single { + MissedPaymentNotificationServiceImpl(get()) + } } diff --git a/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/GetIfMissedPaymentUseCase.kt b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/GetIfMissedPaymentUseCase.kt new file mode 100644 index 0000000000..bf69eb6738 --- /dev/null +++ b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/GetIfMissedPaymentUseCase.kt @@ -0,0 +1,53 @@ +package com.hedvig.android.notification.badge.data.payment + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.safeFlow +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.logcat +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import octopus.MissedPaymentQuery + +interface GetIfMissedPaymentUseCase { + fun invoke(): Flow +} + +internal class GetIfMissedPaymentUseCaseImpl( + private val apolloClient: ApolloClient, +) : GetIfMissedPaymentUseCase { + override fun invoke(): Flow { + return flow { + while (currentCoroutineContext().isActive) { + emitAll( + apolloClient + .query(MissedPaymentQuery()) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .safeFlow { + logcat { "GetIfMissedPaymentUseCaseImpl error: $it" } + ErrorMessage() + } + .map { result -> + result.fold( + { + logcat { "GetIfMissedPaymentUseCaseImpl: error when loading missed payment: $it" } + false + }, + { data -> + data.currentMember.missedChargeIdToChargeManually != null + }, + ) + }, + ) + delay(5.seconds) + } + } + } +} diff --git a/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/MissedPaymentNotificationService.kt b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/MissedPaymentNotificationService.kt new file mode 100644 index 0000000000..0000e0ffb0 --- /dev/null +++ b/app/notification-badge-data/notification-badge-data-public/src/main/kotlin/com/hedvig/android/notification/badge/data/payment/MissedPaymentNotificationService.kt @@ -0,0 +1,32 @@ +package com.hedvig.android.notification.badge.data.payment + +import com.hedvig.android.core.demomode.DemoManager +import com.hedvig.android.core.demomode.ProdOrDemoProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class MissedPaymentNotificationServiceProvider( + override val demoManager: DemoManager, + override val demoImpl: MissedPaymentNotificationService, + override val prodImpl: MissedPaymentNotificationService, +) : ProdOrDemoProvider + +interface MissedPaymentNotificationService { + fun showRedDotNotification(): Flow +} + +internal class DemoMissedPaymentNotificationService : MissedPaymentNotificationService { + var showNotification = false + + override fun showRedDotNotification(): Flow { + return flowOf(showNotification) + } +} + +internal class MissedPaymentNotificationServiceImpl( + private val getIfMissedPaymentUseCase: GetIfMissedPaymentUseCase, +) : MissedPaymentNotificationService { + override fun showRedDotNotification(): Flow { + return getIfMissedPaymentUseCase.invoke() + } +}