Skip to content

Early discount countdown banner #54901

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ad94f68
Create discount component
youssef-lr Dec 22, 2024
f997719
Add banner to subscription page
youssef-lr Dec 22, 2024
72cb79b
Merge branch 'main' into youssef_early_discount
youssef-lr Dec 22, 2024
70ffbfa
Make 25% discount banner dismissable
youssef-lr Dec 24, 2024
47440ab
Merge branch 'main' into youssef_early_discount
youssef-lr Jan 7, 2025
498fc03
Merge branch 'main' of github.com:Expensify/App into parasharrajat/ea…
parasharrajat Jan 13, 2025
6163322
Code cleanup
parasharrajat Jan 13, 2025
f6468dd
add tests for utils
parasharrajat Jan 13, 2025
8ba3ed9
Formatting and translations
parasharrajat Jan 13, 2025
9538707
translations and fixes
parasharrajat Jan 14, 2025
466fdbe
Merge branch 'main' of github.com:Expensify/App into parasharrajat/ea…
parasharrajat Jan 15, 2025
812184c
Fix lint issues
parasharrajat Jan 15, 2025
b2a4b01
Add claim param to API
parasharrajat Jan 16, 2025
aba6f2c
Fix banner styles
parasharrajat Jan 17, 2025
8e8be55
Fix type
parasharrajat Jan 17, 2025
42ac964
message fix
parasharrajat Jan 17, 2025
fe443be
Make the banner reative so that it shows up when the report loads imm…
parasharrajat Jan 17, 2025
b30fd64
Merge branch 'main' of github.com:Expensify/App into parasharrajat/ea…
parasharrajat Jan 17, 2025
4129a23
Small Fix
parasharrajat Jan 17, 2025
4d5be96
Merge branch 'main' of github.com:Expensify/App into parasharrajat/ea…
parasharrajat Jan 21, 2025
d4e9135
Apply suggestions from code review
parasharrajat Jan 21, 2025
7a9084e
small fixes
parasharrajat Jan 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ const CONST = {
MIN_DATE: '0001-01-01',
ORDINAL_DAY_OF_MONTH: 'do',
MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy',
SECONDS_PER_DAY: 24 * 60 * 60,
},
SMS: {
DOMAIN: '@expensify.sms',
Expand Down Expand Up @@ -862,6 +863,7 @@ const CONST = {
get DIRECT_REIMBURSEMENT_CURRENCIES() {
return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR];
},
TRIAL_DURATION_DAYS: 8,
EXAMPLE_PHONE_NUMBER: '+15005550006',
CONCIERGE_CHAT_NAME: 'Concierge',
CLOUDFRONT_URL,
Expand Down
15 changes: 15 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import type {
DeleteActionParams,
DeleteConfirmationParams,
DidSplitAmountMessageParams,
EarlyDiscountSubtitleParams,
EarlyDiscountTitleParams,
EditActionParams,
EditDestinationSubtitleParams,
ElectronicFundsParams,
Expand Down Expand Up @@ -5338,6 +5340,19 @@ const translations = {
title: 'Your free trial has ended',
subtitle: 'Add a payment card to continue using all of your favorite features.',
},
earlyDiscount: {
claimOffer: 'Claim Offer',
noThanks: 'No thanks',
subscriptionPageTitle: {
phrase1: ({discountType}: EarlyDiscountTitleParams) => `${discountType}% off your first year!`,
phrase2: `Just add a payment card and start an annual subscription!`,
},
onboardingChatTitle: {
phrase1: 'Limited time offer:',
phrase2: ({discountType}: EarlyDiscountTitleParams) => `${discountType}% off your first year!`,
},
subtitle: ({days, hours, minutes, seconds}: EarlyDiscountSubtitleParams) => `Claim within ${days > 0 ? `${days}d : ` : ''}${hours}h : ${minutes}m : ${seconds}s`,
},
},
cardSection: {
title: 'Payment',
Expand Down
15 changes: 15 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import type {
DeleteActionParams,
DeleteConfirmationParams,
DidSplitAmountMessageParams,
EarlyDiscountSubtitleParams,
EarlyDiscountTitleParams,
EditActionParams,
EditDestinationSubtitleParams,
ElectronicFundsParams,
Expand Down Expand Up @@ -5855,6 +5857,19 @@ const translations = {
title: 'Tu prueba gratuita ha terminado',
subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.',
},
earlyDiscount: {
claimOffer: 'Solicitar oferta',
noThanks: 'No, gracias',
subscriptionPageTitle: {
phrase1: ({discountType}: EarlyDiscountTitleParams) => `¡${discountType}% de descuento en tu primer año!`,
phrase2: `¡Solo añade una tarjeta de pago y comienza una suscripción anual!`,
},
onboardingChatTitle: {
phrase1: 'Oferta por tiempo limitado:',
phrase2: ({discountType}: EarlyDiscountTitleParams) => `¡${discountType}% de descuento en tu primer año!`,
},
subtitle: ({days, hours, minutes, seconds}: EarlyDiscountSubtitleParams) => `Solicítala en ${days > 0 ? `${days}d : ` : ''}${hours}h : ${minutes}m : ${seconds}s`,
},
},
cardSection: {
title: 'Pago',
Expand Down
6 changes: 6 additions & 0 deletions src/languages/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ type BillingBannerCardOnDisputeParams = {amountOwed: string; cardEnding: string}

type TrialStartedTitleParams = {numOfDays: number};

type EarlyDiscountTitleParams = {discountType: number};
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
type EarlyDiscountTitleParams = {discountType: number};
type EarlyDiscountTitleParams = {discountType: number};

Shouldn't this be called discountAmount?

Copy link
Member Author

Choose a reason for hiding this comment

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

I believe not. the value is 50, 25. Technically, it is not amount. It is percentage of discount. I kept the same terminology used for discount info.


type EarlyDiscountSubtitleParams = {days: number; hours: number; minutes: number; seconds: number};

type CardNextPaymentParams = {nextPaymentDate: string};

type CardEndingParams = {cardNumber: string};
Expand Down Expand Up @@ -642,6 +646,8 @@ export type {
BillingBannerCardExpiredParams,
BillingBannerCardOnDisputeParams,
TrialStartedTitleParams,
EarlyDiscountTitleParams,
EarlyDiscountSubtitleParams,
RemoveMemberPromptParams,
StatementTitleParams,
RenamedWorkspaceNameActionParams,
Expand Down
1 change: 1 addition & 0 deletions src/libs/API/parameters/AddPaymentCardParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ type AddPaymentCardParams = {
addressZip: string;
currency: ValueOf<typeof CONST.PAYMENT_CARD_CURRENCY>;
isP2PDebitCard: boolean;
shouldClaimEarlyDiscountOffer?: boolean;
};
export default AddPaymentCardParams;
8 changes: 7 additions & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,12 @@ let isAnonymousUser = false;
// Example case: when we need to get a report name of a thread which is dependent on a report action message.
const parsedReportActionMessageCache: Record<string, string> = {};

let conciergeChatReportID: string | undefined;
Onyx.connect({
key: ONYXKEYS.CONCIERGE_REPORT_ID,
callback: (value) => (conciergeChatReportID = value),
});

const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon';
Onyx.connect({
key: ONYXKEYS.SESSION,
Expand Down Expand Up @@ -1447,7 +1453,7 @@ function isConciergeChatReport(report: OnyxInputOrEntry<Report>): boolean {
return false;
}

return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING);
return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING) || report?.reportID === conciergeChatReportID;
}

function findSelfDMReportID(): string | undefined {
Expand Down
69 changes: 64 additions & 5 deletions src/libs/SubscriptionUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {differenceInSeconds, fromUnixTime, isAfter, isBefore} from 'date-fns';
import {fromZonedTime} from 'date-fns-tz';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {translateLocal} from './Localize';
import * as PolicyUtils from './PolicyUtils';
import {getOwnedPaidPolicies, isPolicyOwner} from './PolicyUtils';

const PAYMENT_STATUS = {
POLICY_OWNER_WITH_AMOUNT_OWED: 'policy_owner_with_amount_owed',
Expand All @@ -22,11 +24,19 @@ const PAYMENT_STATUS = {
GENERIC_API_ERROR: 'generic_api_error',
} as const;

type DiscountInfo = {
days: number;
hours: number;
minutes: number;
seconds: number;
discountType: number;
};

let currentUserAccountID = -1;
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (value) => {
currentUserAccountID = value?.accountID ?? -1;
currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID;
},
});

Expand Down Expand Up @@ -234,6 +244,53 @@ function hasCardExpiringSoon(): boolean {
return isExpiringThisMonth || isExpiringNextMonth;
}

function shouldShowDiscountBanner(): boolean {
if (!isUserOnFreeTrial()) {
return false;
}

if (doesUserHavePaymentCardAdded()) {
return false;
}

const dateNow = Math.floor(Date.now() / 1000);
const firstDayTimestamp = fromZonedTime(`${firstDayFreeTrial}`, 'UTC').getTime() / 1000;
const lastDayTimestamp = fromZonedTime(`${lastDayFreeTrial}`, 'UTC').getTime() / 1000;
if (dateNow > lastDayTimestamp) {
return false;
}

return dateNow <= firstDayTimestamp + CONST.TRIAL_DURATION_DAYS * CONST.DATE.SECONDS_PER_DAY;
}

function getEarlyDiscountInfo(): DiscountInfo | null {
if (!firstDayFreeTrial) {
return null;
}
const dateNow = Math.floor(Date.now() / 1000);
const firstDayTimestamp = fromZonedTime(`${firstDayFreeTrial}`, 'UTC').getTime() / 1000;

let timeLeftInSeconds;
const timeLeft24 = CONST.DATE.SECONDS_PER_DAY - (dateNow - firstDayTimestamp);
if (timeLeft24 > 0) {
timeLeftInSeconds = timeLeft24;
} else {
timeLeftInSeconds = firstDayTimestamp + CONST.TRIAL_DURATION_DAYS * CONST.DATE.SECONDS_PER_DAY - dateNow;
}

if (timeLeftInSeconds <= 0) {
return null;
}

return {
days: Math.floor(timeLeftInSeconds / CONST.DATE.SECONDS_PER_DAY),
hours: Math.floor((timeLeftInSeconds % CONST.DATE.SECONDS_PER_DAY) / 3600),
minutes: Math.floor((timeLeftInSeconds % 3600) / 60),
seconds: Math.floor(timeLeftInSeconds % 60),
discountType: timeLeft24 > 0 ? 50 : 25,
};
}

/**
* @returns Whether there is a retry billing error.
*/
Expand Down Expand Up @@ -385,7 +442,7 @@ function calculateRemainingFreeTrialDays(): number {
* @returns The free trial badge text .
*/
function getFreeTrialText(policies: OnyxCollection<Policy> | null): string | undefined {
const ownedPaidPolicies = PolicyUtils.getOwnedPaidPolicies(policies, currentUserAccountID);
const ownedPaidPolicies = getOwnedPaidPolicies(policies, currentUserAccountID);
if (isEmptyObject(ownedPaidPolicies)) {
return undefined;
}
Expand Down Expand Up @@ -456,7 +513,7 @@ function shouldRestrictUserBillableActions(policyID: string): boolean {
// Extracts the owner account ID from the collection member key.
const ownerAccountID = Number(entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length));

if (PolicyUtils.isPolicyOwner(policy, ownerAccountID)) {
if (isPolicyOwner(policy, ownerAccountID)) {
return true;
}
}
Expand All @@ -465,7 +522,7 @@ function shouldRestrictUserBillableActions(policyID: string): boolean {
// If it reached here it means that the user is actually the workspace's owner.
// We should restrict the workspace's owner actions if it's past its grace period end date and it's owing some amount.
if (
PolicyUtils.isPolicyOwner(policy, currentUserAccountID) &&
isPolicyOwner(policy, currentUserAccountID) &&
ownerBillingGraceEndPeriod &&
amountOwed !== undefined &&
amountOwed > 0 &&
Expand Down Expand Up @@ -494,4 +551,6 @@ export {
PAYMENT_STATUS,
shouldRestrictUserBillableActions,
shouldShowPreTrialBillingBanner,
shouldShowDiscountBanner,
getEarlyDiscountInfo,
};
1 change: 1 addition & 0 deletions src/libs/actions/PaymentMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ function addSubscriptionPaymentCard(
addressZip,
currency,
isP2PDebitCard: false,
shouldClaimEarlyDiscountOffer: true,
};

const optimisticData: OnyxUpdate[] = [
Expand Down
Loading
Loading