diff --git a/assets/images/integrationicons/exact-online-icon-square.svg b/assets/images/integrationicons/exact-online-icon-square.svg
new file mode 100644
index 000000000000..104d1617da63
--- /dev/null
+++ b/assets/images/integrationicons/exact-online-icon-square.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index f1bb8ae3e70d..d6e185ba7ff5 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1192,6 +1192,7 @@ const CONST = {
BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay',
VENDOR_MATCHING: 'vendorMatching',
BUSINESS_CENTRAL: 'businessCentral',
+ EXACT_ONLINE: 'exactOnline2026',
COMMUTER_EXCLUSIONS: 'commuterExclusions',
COMMUTER_EXCLUSIONS_ARRANGEMENTS: 'commuterExclusionsArrangements',
MULTIPLE_APPROVERS: 'multipleApprovers',
@@ -5031,6 +5032,7 @@ const CONST = {
CAMPFIRE: 'campfire',
ZOHO_BOOKS: 'zohoBooks',
BUSINESS_CENTRAL: 'businessCentral',
+ EXACT_ONLINE: 'exactOnline',
GUSTO: 'gusto',
ZENEFITS: 'zenefits',
MERGE_HR: 'merge_hris',
@@ -5069,6 +5071,7 @@ const CONST = {
campfire: 'Campfire',
zohoBooks: 'Zoho Books',
businessCentral: 'Dynamics 365 Business Central',
+ exactOnline: 'Exact Online',
gusto: 'Gusto',
billCom: 'Bill.com',
zenefits: 'TriNet',
@@ -10267,6 +10270,7 @@ const CONST = {
},
ACCOUNTING: {
SETUP_BUTTON: 'WorkspaceAccounting-SetupButton',
+ EXACT_ONLINE_WAITLIST_BUTTON: 'WorkspaceAccounting-ExactOnlineWaitlistButton',
CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton',
THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu',
},
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index e1195cb87f7f..ecc06d0999c3 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -196,6 +196,9 @@ const ONYXKEYS = {
/** NVP keys */
+ /** UTC signup timestamp for the account's Exact Online waitlist membership */
+ NVP_EXACT_ONLINE_WAITLIST: 'nvp_expensify_exactOnlineWaitlist',
+
/** This NVP contains list of at most 5 recent attendees */
NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees',
@@ -1784,6 +1787,7 @@ type OnyxValuesMapping = {
// The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked
[ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string;
[ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string;
+ [ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST]: string;
[ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[];
[ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean;
[ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean;
diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts
index 48cf25d3c3cd..a07fa8396598 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -145,6 +145,7 @@ import CircleSlash from '@assets/images/integrationicons/circle-slash.svg';
import ClaudeSquare from '@assets/images/integrationicons/claude-icon-square.svg';
import CursorSquare from '@assets/images/integrationicons/cursor-icon-square.svg';
import DualEntrySquare from '@assets/images/integrationicons/dualentry-icon-square.svg';
+import ExactOnlineSquare from '@assets/images/integrationicons/exact-online-icon-square.svg';
import NetSuiteExport from '@assets/images/integrationicons/export/netsuite-icon.svg';
import QBOExport from '@assets/images/integrationicons/export/qbo-icon.svg';
import SageIntacctExport from '@assets/images/integrationicons/export/sage-intacct-icon.svg';
@@ -505,6 +506,7 @@ const Expensicons = {
ReceiptMultiple,
RilletSquare,
DualEntrySquare,
+ ExactOnlineSquare,
Rotate,
RotateLeft,
Scan,
diff --git a/src/languages/TranslationTypes.ts b/src/languages/TranslationTypes.ts
index 2ff1b97b0a59..22b6b4eaacc7 100644
--- a/src/languages/TranslationTypes.ts
+++ b/src/languages/TranslationTypes.ts
@@ -73,7 +73,8 @@ type PersonalRulesModifiedFields = PolicyRulesModifiedFields & {
reportName?: string;
};
-type ConnectionName = ValueOf;
+// Exact Online only has a waitlist until its connector is released.
+type ConnectionName = Exclude, typeof CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE>;
type AllConnectionName = ConnectionName;
type PolicyConnectionSyncStage = ValueOf;
type SageIntacctMappingName = ValueOf;
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 71ea8fde1729..4f3f6b050539 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -7468,6 +7468,12 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Bei einer in Expensify Classic eingerichteten Verbindung ist ein Fehler aufgetreten. [Wechsle zu Expensify Classic, um dieses Problem zu beheben.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Wechsle zu Expensify Classic, um deine Einstellungen zu verwalten.',
+ exactOnline: {
+ comingSoon: 'Demnächst verfügbar',
+ joinWaitlist: 'Warteliste beitreten',
+ onWaitlist: 'Sie stehen auf der Warteliste',
+ waitlistDescription: 'Concierge sendet Ihnen eine Nachricht, sobald diese Integration bereit ist, und erklärt Ihnen, wie Sie die Verbindung herstellen.',
+ },
setup: 'Verbinden',
connectToSandbox: 'Mit Sandbox verbinden',
lastSync: (relativeDate: string) => `Zuletzt synchronisiert ${relativeDate}`,
diff --git a/src/languages/el.ts b/src/languages/el.ts
index 81c2f58d47a6..50cd76d2a45c 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -7601,6 +7601,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Προέκυψε σφάλμα με μια σύνδεση που έχει ρυθμιστεί στο Expensify Classic. [Μεταβείτε στο Expensify Classic για να διορθώσετε αυτό το ζήτημα.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Μεταβείτε στο Expensify Classic για να διαχειριστείτε τις ρυθμίσεις σας.',
+ exactOnline: {
+ comingSoon: 'Έρχεται σύντομα',
+ joinWaitlist: 'Εγγραφείτε στη λίστα αναμονής',
+ onWaitlist: 'Είστε σε λίστα αναμονής',
+ waitlistDescription: 'Ο Concierge θα σας στείλει ένα μήνυμα για να σας ενημερώσει πότε θα είναι έτοιμη αυτή η ενσωμάτωση και πώς να τη συνδέσετε.',
+ },
setup: 'Σύνδεση',
connectToSandbox: 'Σύνδεση στο sandbox',
lastSync: (relativeDate: string) => `Τελευταίος συγχρονισμός ${relativeDate}`,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 19cbc7fcd8d3..c2113c447719 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -7865,6 +7865,12 @@ const translations = {
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`There's an error with a connection that's been set up in Expensify Classic. [Go to Expensify Classic to fix this issue.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Go to Expensify Classic to manage your settings.',
+ exactOnline: {
+ comingSoon: 'Coming soon',
+ joinWaitlist: 'Join waitlist',
+ onWaitlist: 'You’re on the waitlist',
+ waitlistDescription: 'Concierge will send you a message to let you know when this integration is ready and how to connect.',
+ },
setup: 'Connect',
connectToSandbox: 'Connect to sandbox',
lastSync: (relativeDate: string) => `Last synced ${relativeDate}`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index ce510f051660..7b23e0e9a3b4 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -7266,6 +7266,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
errorODIntegration: (oldDotPolicyConnectionsURL) =>
`Hay un error con una conexión que se ha configurado en Expensify Classic. [Ve a Expensify Classic para solucionar este problema.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Ve a Expensify Classic para gestionar tus configuraciones.',
+ exactOnline: {
+ comingSoon: 'Próximamente',
+ joinWaitlist: 'Unirse a la lista de espera',
+ onWaitlist: 'Estás en la lista de espera',
+ waitlistDescription: 'Concierge te enviará un mensaje para avisarte cuando esta integración esté lista y cómo conectarla.',
+ },
setup: 'Configurar',
connectToSandbox: 'Conectar con Sandbox',
lastSync: (relativeDate) => `Recién sincronizado ${relativeDate}`,
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index a6a9b9ca24fb..a4006f19a98a 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -7488,6 +7488,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Une erreur s’est produite avec une connexion configurée dans Expensify Classic. [Accédez à Expensify Classic pour corriger ce problème.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Accédez à Expensify Classic pour gérer vos paramètres.',
+ exactOnline: {
+ comingSoon: 'Bientôt disponible',
+ joinWaitlist: 'Rejoindre la liste d’attente',
+ onWaitlist: 'Vous êtes sur la liste d’attente',
+ waitlistDescription: 'Concierge vous enverra un message pour vous informer lorsque cette intégration sera prête et vous expliquer comment la connecter.',
+ },
setup: 'Connecter',
connectToSandbox: 'Se connecter à Sandbox',
lastSync: (relativeDate: string) => `Dernière synchronisation ${relativeDate}`,
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 769669d5b813..75865b8517c4 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -7425,6 +7425,12 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Si è verificato un errore con una connessione configurata in Expensify Classic. [Vai a Expensify Classic per risolvere questo problema.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Vai a Expensify Classic per gestire le tue impostazioni.',
+ exactOnline: {
+ comingSoon: 'Presto in arrivo',
+ joinWaitlist: 'Unisciti alla lista d’attesa',
+ onWaitlist: 'Sei in lista d’attesa',
+ waitlistDescription: 'Concierge ti invierà un messaggio per farti sapere quando questa integrazione sarà pronta e come collegarla.',
+ },
setup: 'Connetti',
connectToSandbox: 'Connetti alla sandbox',
lastSync: (relativeDate: string) => `Ultima sincronizzazione ${relativeDate}`,
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index fccf6ada6df2..93a05b64385a 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -7334,6 +7334,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Expensify Classic で設定されている接続にエラーがあります。[この問題を解決するには Expensify Classic に移動してください。](${oldDotPolicyConnectionsURL})`,
goToODToSettings: '設定を管理するには、Expensify Classic に移動してください。',
+ exactOnline: {
+ comingSoon: '近日公開予定',
+ joinWaitlist: 'ウェイトリストに参加',
+ onWaitlist: '順番待ちリストに登録されています',
+ waitlistDescription: 'この連携の準備が整い、接続方法が決まりましたら、Concierge からメッセージでお知らせします。',
+ },
setup: '接続',
connectToSandbox: 'Sandbox に接続',
lastSync: (relativeDate: string) => `最終同期:${relativeDate}`,
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 17501a00c19c..13b8e5df07a1 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -7408,6 +7408,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Er is een fout opgetreden met een verbinding die is ingesteld in Expensify Classic. [Ga naar Expensify Classic om dit probleem op te lossen.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Ga naar Expensify Classic om je instellingen te beheren.',
+ exactOnline: {
+ comingSoon: 'Binnenkort beschikbaar',
+ joinWaitlist: 'Aanmelden voor wachtlijst',
+ onWaitlist: 'Je staat op de wachtlijst',
+ waitlistDescription: 'Concierge stuurt je een bericht om je te laten weten wanneer deze integratie klaar is en hoe je verbinding kunt maken.',
+ },
setup: 'Verbinden',
connectToSandbox: 'Verbinden met sandbox',
lastSync: (relativeDate: string) => `Laatst gesynchroniseerd ${relativeDate}`,
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index fdebdb2ec306..f0ff574fc921 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -7434,6 +7434,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Wystąpił błąd z połączeniem skonfigurowanym w Expensify Classic. [Przejdź do Expensify Classic, aby rozwiązać ten problem.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Przejdź do Expensify Classic, aby zarządzać swoimi ustawieniami.',
+ exactOnline: {
+ comingSoon: 'Wkrótce dostępne',
+ joinWaitlist: 'Dołącz do listy oczekujących',
+ onWaitlist: 'Jesteś na liście oczekujących',
+ waitlistDescription: 'Concierge wyśle ci wiadomość, żeby dać znać, kiedy ta integracja będzie gotowa i jak ją połączyć.',
+ },
setup: 'Połącz',
connectToSandbox: 'Połącz z sandboxem',
lastSync: (relativeDate: string) => `Ostatnia synchronizacja ${relativeDate}`,
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 4fcc3490687d..9f80a02c72ed 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -7410,6 +7410,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`,
errorODIntegration: (oldDotPolicyConnectionsURL: string) =>
`Há um erro em uma conexão configurada no Expensify Classic. [Acesse o Expensify Classic para corrigir esse problema.](${oldDotPolicyConnectionsURL})`,
goToODToSettings: 'Vá para o Expensify Classic para gerenciar suas configurações.',
+ exactOnline: {
+ comingSoon: 'Em breve',
+ joinWaitlist: 'Entrar na lista de espera',
+ onWaitlist: 'Você está na lista de espera',
+ waitlistDescription: 'O Concierge vai enviar uma mensagem para avisar quando essa integração estiver pronta e como conectá-la.',
+ },
setup: 'Conectar',
connectToSandbox: 'Conectar ao sandbox',
lastSync: (relativeDate: string) => `Última sincronização ${relativeDate}`,
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 39a6816c5c9f..eb0422c556ec 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -7148,6 +7148,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
},
errorODIntegration: (oldDotPolicyConnectionsURL: string) => `Expensify Classic 中已设置的某个连接出错。[前往 Expensify Classic 解决此问题](${oldDotPolicyConnectionsURL})`,
goToODToSettings: '前往 Expensify Classic 管理您的设置。',
+ exactOnline: {
+ comingSoon: '即将推出',
+ joinWaitlist: '加入候补名单',
+ onWaitlist: '您在候补名单中',
+ waitlistDescription: '当此集成准备就绪并可连接时,Concierge 会向你发送消息通知你并说明如何连接。',
+ },
setup: '连接',
connectToSandbox: '连接到沙盒',
lastSync: (relativeDate: string) => `上次同步于 ${relativeDate}`,
diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts
index 2c47235d909f..b8d41b3d78c9 100644
--- a/src/libs/ExportOnyxState/masking.ts
+++ b/src/libs/ExportOnyxState/masking.ts
@@ -294,6 +294,7 @@ const safeOnyxKeys = new Set([
ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION,
ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER,
ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED,
+ ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST,
ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH,
ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO,
ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index c60f3ab107ba..c33541efc048 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -1331,6 +1331,55 @@ function setNameValuePair(name: TKey, value: SetNameValueP
});
}
+function joinExactOnlineWaitlist() {
+ const signupTimestamp = DateUtils.getDBTime();
+ const optimisticData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
+ errorFields: {exactOnlineWaitlist: null},
+ },
+ },
+ ];
+ // The confirmation modal must not treat a queued signup as confirmed membership.
+ const successData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST,
+ value: signupTimestamp,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {pendingFields: {exactOnlineWaitlist: null}},
+ },
+ ];
+ const failureData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST,
+ value: '',
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ pendingFields: {exactOnlineWaitlist: null},
+ errorFields: {exactOnlineWaitlist: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')},
+ },
+ },
+ ];
+
+ // The queued request keeps this timestamp when it retries, preserving the original signup time.
+ API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp}, {optimisticData, successData, failureData});
+}
+
+function clearExactOnlineWaitlistError() {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: null}});
+}
+
function dismissMarketingWindow(updateKey: string) {
const optimisticData: AnyOnyxUpdate[] = [
{
@@ -2039,6 +2088,8 @@ export {
clearDraftCustomStatus,
requestRefund,
setNameValuePair,
+ joinExactOnlineWaitlist,
+ clearExactOnlineWaitlistError,
setSubmitMigrationModalShown,
clearUnvalidatedNewContactMethodAction,
clearPendingContactActionErrors,
diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx
new file mode 100644
index 000000000000..8a3eca80219d
--- /dev/null
+++ b/src/pages/workspace/accounting/ExactOnlineWaitlistButton.tsx
@@ -0,0 +1,90 @@
+/** Account-wide signup for Exact Online before the accounting connector is available. */
+import Button from '@components/Button';
+import Text from '@components/Text';
+
+import useConfirmModal from '@hooks/useConfirmModal';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {joinExactOnlineWaitlist} from '@userActions/User';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy} from '@src/types/onyx';
+
+import type {OnyxEntry} from 'react-native-onyx';
+
+import React, {useEffect, useRef} from 'react';
+
+type ExactOnlineWaitlistButtonProps = {
+ policy: OnyxEntry;
+};
+
+function ExactOnlineWaitlistButton({policy}: ExactOnlineWaitlistButtonProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {showConfirmModal} = useConfirmModal();
+ const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING);
+ const [signupTimestamp, signupTimestampMetadata] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST);
+ const [pendingAction, pendingActionMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist});
+ const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist});
+ const isJoiningWaitlist = useRef(false);
+
+ // Queued writes finish through Onyx, including retries after the press handler has returned.
+ useEffect(() => {
+ if (!isJoiningWaitlist.current || pendingAction || pendingActionMetadata.status === 'loading') {
+ return;
+ }
+ if (errors) {
+ isJoiningWaitlist.current = false;
+ return;
+ }
+ if (!signupTimestamp) {
+ return;
+ }
+
+ isJoiningWaitlist.current = false;
+ showConfirmModal({
+ title: translate('workspace.accounting.exactOnline.onWaitlist'),
+ prompt: translate('workspace.accounting.exactOnline.waitlistDescription'),
+ confirmText: translate('common.buttonConfirm'),
+ shouldShowCancelButton: false,
+ });
+ }, [errors, pendingAction, pendingActionMetadata.status, showConfirmModal, signupTimestamp, translate]);
+
+ if (signupTimestamp && !pendingAction) {
+ return {translate('workspace.accounting.exactOnline.onWaitlist')};
+ }
+
+ const joinWaitlist = () => {
+ if (isJoiningWaitlist.current || pendingAction) {
+ return;
+ }
+ if (!canWrite) {
+ showReadOnlyModal();
+ return;
+ }
+ // Prevent repeated presses before the pending account state reaches this component.
+ isJoiningWaitlist.current = true;
+ joinExactOnlineWaitlist();
+ };
+
+ return (
+
+ );
+}
+
+export default ExactOnlineWaitlistButton;
diff --git a/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx
new file mode 100644
index 000000000000..4c3a21376252
--- /dev/null
+++ b/src/pages/workspace/accounting/ExactOnlineWaitlistRow.tsx
@@ -0,0 +1,85 @@
+/** Shared Exact Online identity and signup state for Accounting and Connections. */
+import UserAvatar from '@components/Avatar/UserAvatar';
+import Badge from '@components/Badge';
+import MenuItem from '@components/MenuItem';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import getPlatform from '@libs/getPlatform';
+
+import {clearExactOnlineWaitlistError} from '@userActions/User';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy} from '@src/types/onyx';
+
+import type {StyleProp, ViewStyle} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import ExactOnlineWaitlistButton from './ExactOnlineWaitlistButton';
+
+type ExactOnlineWaitlistRowProps = {
+ policy: OnyxEntry;
+ style?: StyleProp;
+ titleWrapperStyle?: StyleProp;
+ isBadgeCondensed?: boolean;
+};
+
+function ExactOnlineWaitlistRow({policy, style, titleWrapperStyle, isBadgeCondensed}: ExactOnlineWaitlistRowProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const icons = useMemoizedLazyExpensifyIcons(['ExactOnlineSquare']);
+ const [signupTimestamp] = useOnyx(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST);
+ const [errors] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.errorFields?.exactOnlineWaitlist});
+ const [pendingAction] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.pendingFields?.exactOnlineWaitlist});
+ const title = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline;
+ const comingSoon = translate('workspace.accounting.exactOnline.comingSoon');
+ const accessibilityLabel = [title, comingSoon, signupTimestamp && !pendingAction && translate('workspace.accounting.exactOnline.onWaitlist')].filter(Boolean).join(', ');
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ExactOnlineWaitlistRow;
diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
index 76519f4b39af..90df82a52eae 100644
--- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx
+++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
@@ -47,6 +47,7 @@ import {View} from 'react-native';
import type {MenuItemData, PolicyAccountingPageProps} from './types';
import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext';
+import ExactOnlineWaitlistRow from './ExactOnlineWaitlistRow';
import useConnectedAccountingIntegration from './useConnectedAccountingIntegration';
type RouteParams = {
@@ -398,6 +399,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) {
/>
)}
+
{!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && (
- {listings.map((listing) => (
-
- ))}
+ {listings.map((listing) =>
+ listing.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE ? (
+
+ ) : (
+
+ ),
+ )}
{!shouldStackCards &&
Array.from({length: FILLER_COUNT}, (_, index) => (
+
+
+ );
+}
+
+export default ExactOnlineWaitlistCard;
diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx
index 7c1dba68a69d..ea73b8b996d4 100644
--- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx
+++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx
@@ -299,7 +299,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) {
>
{connectedListings.length > 0 && (
-
+
)}
0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}>
@@ -340,7 +343,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) {
/>
) : (
-
+
)}
{!shouldShowEmptyState && (
diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts
index 23f2b9381c27..a2fa41dd0ef6 100644
--- a/src/pages/workspace/connections/types.ts
+++ b/src/pages/workspace/connections/types.ts
@@ -35,7 +35,7 @@ type ConnectionListing = {
/** Only set while the integration is connected to the workspace */
status?: ConnectionStatus;
- onConnect: () => void;
+ onConnect?: () => void;
/** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */
offer?: {onPress?: () => void};
diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts
index a213688c9911..6a630d1ef2f3 100644
--- a/src/pages/workspace/connections/useAccountingConnectionListings.ts
+++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts
@@ -120,7 +120,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL
...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []),
]);
- return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => {
+ const listings: ConnectionListing[] = integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => {
const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, {
expensifyIcons: accountingIcons,
isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite,
@@ -155,6 +155,14 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL
},
};
});
+
+ // The connector flow arrives in a later release, so beta users also see the waitlist for now.
+ listings.push({
+ key: CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE,
+ category: CONST.TAB.CONNECTIONS.ACCOUNTING,
+ title: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.exactOnline,
+ });
+ return listings;
}
export default useAccountingConnectionListings;
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
index 8f8683689812..189416870ce3 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -136,6 +136,9 @@ type Account = {
/** Errors related to specific account fields */
errorFields?: OnyxCommon.ErrorFields;
+ /** Pending account changes while requests are queued. */
+ pendingFields?: OnyxCommon.PendingFields<'exactOnlineWaitlist'>;
+
/** Authentication success message */
success?: string;
diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts
index 9c0a92698f4b..fa52414a556a 100644
--- a/tests/actions/UserTest.ts
+++ b/tests/actions/UserTest.ts
@@ -1,6 +1,7 @@
import * as ActiveClientManager from '@libs/ActiveClientManager';
import * as API from '@libs/API';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import DateUtils from '@libs/DateUtils';
import {getLatestErrorField} from '@libs/ErrorUtils';
import Log from '@libs/Log';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
@@ -45,6 +46,52 @@ describe('actions/User', () => {
return Onyx.clear().then(waitForBatchedUpdates);
});
+ describe('joinExactOnlineWaitlist', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('queues the signup timestamp with a rollback to the absent value', () => {
+ // Given the account has not joined the waitlist
+ const signupTimestamp = '2026-10-14 10:00:00.000';
+ const errorTimestamp = 1;
+ const getDBTime = jest.spyOn(DateUtils, 'getDBTime').mockReturnValue(signupTimestamp);
+ jest.spyOn(DateUtils, 'getMicroseconds').mockReturnValue(errorTimestamp);
+
+ // When the account joins the waitlist
+ UserActions.joinExactOnlineWaitlist();
+
+ // Then the queued request and confirmed signup share one timestamp, with the previous value restored on failure
+ expect(getDBTime).toHaveBeenCalledTimes(1);
+ expect(mockAPI.write).toHaveBeenCalledTimes(1);
+ expect(mockAPI.write).toHaveBeenCalledWith(
+ WRITE_COMMANDS.SET_NAME_VALUE_PAIR,
+ {name: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp},
+ {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}},
+ },
+ ],
+ successData: [
+ {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: signupTimestamp},
+ {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, value: {pendingFields: {exactOnlineWaitlist: null}}},
+ ],
+ failureData: [
+ {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[errorTimestamp]: anyString}}},
+ },
+ ],
+ },
+ );
+ });
+ });
+
describe('clearContactMethod', () => {
it('should return early when contactMethods array is empty', async () => {
// Given an empty array
diff --git a/tests/unit/ExactOnlineConnectionListingTest.tsx b/tests/unit/ExactOnlineConnectionListingTest.tsx
new file mode 100644
index 000000000000..c2e852ab1567
--- /dev/null
+++ b/tests/unit/ExactOnlineConnectionListingTest.tsx
@@ -0,0 +1,107 @@
+import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native';
+
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+
+import ConnectionsGrid from '@pages/workspace/connections/ConnectionsGrid';
+import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings';
+
+import {clearExactOnlineWaitlistError, joinExactOnlineWaitlist} from '@userActions/User';
+
+import CONST from '@src/CONST';
+import IntlStore from '@src/languages/IntlStore';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import React from 'react';
+import Onyx from 'react-native-onyx';
+
+import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal';
+
+import createRandomPolicy from '../utils/collections/policies';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+let mockEnabledBetas: string[] = [];
+const mockStartIntegrationFlow = jest.fn();
+const mockConnect = jest.fn();
+
+jest.mock('@userActions/User', () => ({clearExactOnlineWaitlistError: jest.fn(), joinExactOnlineWaitlist: jest.fn()}));
+jest.mock('@hooks/useConfirmModal', () => {
+ const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal');
+ return mockUseConfirmModal;
+});
+jest.mock('@hooks/usePermissions', () => ({
+ __esModule: true,
+ default: () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)}),
+}));
+jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true);
+jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({
+ __esModule: true,
+ default: () => ({canWrite: true, showReadOnlyModal: jest.fn()}),
+}));
+jest.mock('@pages/workspace/accounting/AccountingContext', () => ({
+ useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}),
+ useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}),
+}));
+
+function TestWrapper({children}: {children: React.ReactNode}) {
+ return {children};
+}
+
+describe('Exact Online in unified Connections', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ return IntlStore.load(CONST.LOCALES.EN);
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await Onyx.clear();
+ await waitForBatchedUpdates();
+ });
+
+ it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('keeps signup available offline with Exact beta enrollment $enabledBetas', async ({enabledBetas}) => {
+ // Given unified Connections is enabled, the account is offline, and the Exact beta may be enabled
+ mockEnabledBetas = enabledBetas;
+ await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true});
+ const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace');
+ await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy);
+ const {result} = renderHook(() => useAccountingConnectionListings(policy), {wrapper: TestWrapper});
+ await waitForBatchedUpdatesWithAct();
+ const listing = result.current.find((item) => item.key === CONST.POLICY.CONNECTIONS.NAME.EXACT_ONLINE);
+ if (!listing) {
+ throw new Error('Exact Online waitlist is missing from Connections');
+ }
+
+ // When the connection cards render and the user chooses the waitlist
+ render(
+ ,
+ {wrapper: TestWrapper},
+ );
+ await waitForBatchedUpdatesWithAct();
+ fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'}));
+ fireEvent.press(screen.getByRole('button', {name: 'Existing connector', disabled: true}));
+
+ // Then Exact offers coming-soon signup offline while live connectors retain their offline restriction
+ expect(screen.getByText('Coming soon')).toBeOnTheScreen();
+ expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1);
+ expect(mockStartIntegrationFlow).not.toHaveBeenCalled();
+ expect(mockConnect).not.toHaveBeenCalled();
+ expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen();
+
+ await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00'));
+ expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen();
+
+ await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null));
+ expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen();
+
+ await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed. Please try again.'}}}));
+ expect(screen.getByText('Signup failed. Please try again.')).toBeOnTheScreen();
+ fireEvent.press(screen.getByRole('button', {name: 'Dismiss'}));
+ expect(clearExactOnlineWaitlistError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/ExactOnlineWaitlistButtonTest.tsx b/tests/unit/ExactOnlineWaitlistButtonTest.tsx
new file mode 100644
index 000000000000..3899f9108ff1
--- /dev/null
+++ b/tests/unit/ExactOnlineWaitlistButtonTest.tsx
@@ -0,0 +1,148 @@
+import {act, fireEvent, render, screen} from '@testing-library/react-native';
+
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+
+import ExactOnlineWaitlistButton from '@pages/workspace/accounting/ExactOnlineWaitlistButton';
+
+import {joinExactOnlineWaitlist} from '@userActions/User';
+
+import CONST from '@src/CONST';
+import IntlStore from '@src/languages/IntlStore';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import React from 'react';
+import Onyx from 'react-native-onyx';
+
+import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal';
+
+import createRandomPolicy from '../utils/collections/policies';
+import {getShowConfirmModalOption, mockShowConfirmModal, resetMockConfirmModal} from '../utils/mockUseConfirmModal';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+const mockShowReadOnlyModal = jest.fn();
+let mockCanWriteAccounting = true;
+const policy = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'First workspace');
+const otherPolicy = createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE, 'Second workspace');
+
+jest.mock('@userActions/User', () => ({joinExactOnlineWaitlist: jest.fn()}));
+jest.mock('@hooks/useConfirmModal', () => {
+ const {default: mockUseConfirmModal} = jest.requireActual('../utils/mockUseConfirmModal');
+ return mockUseConfirmModal;
+});
+jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({
+ __esModule: true,
+ default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: mockShowReadOnlyModal}),
+}));
+
+function TestWrapper({children}: {children: React.ReactNode}) {
+ return {children};
+}
+
+describe('Exact Online waitlist signup', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ return IntlStore.load(CONST.LOCALES.EN);
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ resetMockConfirmModal();
+ mockCanWriteAccounting = true;
+ await Onyx.clear();
+ await waitForBatchedUpdates();
+ });
+
+ it('queues signup offline, confirms success once, and shares signup status across workspaces', async () => {
+ // Given an account that has not joined the waitlist and is currently offline
+ await Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: true});
+ const {rerender} = render(, {wrapper: TestWrapper});
+ await waitForBatchedUpdatesWithAct();
+
+ // When the user presses Join waitlist twice before the optimistic value renders
+ const button = screen.getByRole('button', {name: 'Join waitlist'});
+ fireEvent.press(button);
+ fireEvent.press(button);
+
+ // Then signup is queued once without claiming success while offline
+ expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(1);
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+
+ await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}));
+ rerender();
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen();
+
+ // When the request fails and its optimistic account value is rolled back
+ await act(() =>
+ Onyx.update([
+ {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, value: ''},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {pendingFields: {exactOnlineWaitlist: null}, errorFields: {exactOnlineWaitlist: {[Date.now()]: 'Signup failed'}}},
+ },
+ ]),
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ // Then signup is offered again
+ expect(screen.getByRole('button', {name: 'Join waitlist', disabled: false})).toBeOnTheScreen();
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+
+ // When the retried signup succeeds and the user switches workspaces
+ fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'}));
+ await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, errorFields: {exactOnlineWaitlist: null}}));
+ await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-14 12:00:00'));
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+ await act(() => Onyx.set(ONYXKEYS.NETWORK, {shouldForceOffline: false}));
+ await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}}));
+ rerender();
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the same account signup is shown without another action in the new workspace
+ expect(screen.getByText('You’re on the waitlist')).toBeTruthy();
+ expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull();
+ expect(joinExactOnlineWaitlist).toHaveBeenCalledTimes(2);
+ expect(mockShowConfirmModal).toHaveBeenCalledTimes(1);
+ expect(getShowConfirmModalOption('title')).toBe('You’re on the waitlist');
+ expect(getShowConfirmModalOption('prompt')).toBe('Concierge will send you a message to let you know when this integration is ready and how to connect.');
+ expect(getShowConfirmModalOption('shouldShowCancelButton')).toBe(false);
+ });
+
+ it('loads an existing account signup without offering another signup', async () => {
+ // Given an account that joined the waitlist in an earlier session
+ await Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-13 12:00:00');
+ await Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}});
+
+ // When the user opens accounting in another workspace
+ render(, {wrapper: TestWrapper});
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.getByRole('button', {name: 'Join waitlist', disabled: true})).toBeOnTheScreen();
+ await act(() => Onyx.merge(ONYXKEYS.ACCOUNT, {pendingFields: {exactOnlineWaitlist: null}}));
+ await waitForBatchedUpdatesWithAct();
+
+ // Then their stored signup replaces the action without opening an acknowledgement
+ expect(screen.getByText('You’re on the waitlist')).toBeTruthy();
+ expect(screen.queryByRole('button', {name: 'Join waitlist'})).toBeNull();
+ expect(joinExactOnlineWaitlist).not.toHaveBeenCalled();
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+ });
+
+ it('shows the read-only explanation without signing up when accounting write access is denied', async () => {
+ // Given a workspace where the user has read-only accounting access
+ mockCanWriteAccounting = false;
+ render(, {wrapper: TestWrapper});
+ await waitForBatchedUpdatesWithAct();
+
+ // When the user presses Join waitlist
+ fireEvent.press(screen.getByRole('button', {name: 'Join waitlist'}));
+
+ // Then the standard read-only explanation appears and the account stays off the waitlist
+ expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1);
+ expect(joinExactOnlineWaitlist).not.toHaveBeenCalled();
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx
index f1b9acae0807..08fcf313e23b 100644
--- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx
+++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx
@@ -1,8 +1,8 @@
-import {fireEvent, render, screen, within} from '@testing-library/react-native';
+import {act, fireEvent, render, screen, within} from '@testing-library/react-native';
import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
-import type {MenuItemProps} from '@components/MenuItem';
+import type * as MenuItemModule from '@components/MenuItem';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import Navigation from '@libs/Navigation/Navigation';
@@ -11,6 +11,7 @@ import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPa
import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
+import ONYXKEYS from '@src/ONYXKEYS';
import type {ConnectionName} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
@@ -18,6 +19,7 @@ import type * as ReactNavigation from '@react-navigation/native';
import type {View} from 'react-native';
import React from 'react';
+import Onyx from 'react-native-onyx';
import createRandomPolicy from '../utils/collections/policies';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
@@ -117,9 +119,10 @@ jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default:
jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null}));
jest.mock('@components/MenuItem', () => {
const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native');
+ const {default: ActualMenuItem} = jest.requireActual('@components/MenuItem');
return {
__esModule: true,
- default: ({title, rightComponent}: MenuItemProps) => {rightComponent},
+ default: Object.assign(({title, rightComponent}: MenuItemModule.MenuItemProps) => {rightComponent}, ActualMenuItem),
};
});
jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children}));
@@ -258,4 +261,26 @@ describe('PolicyAccountingPage auto-started connect flow', () => {
expect(screen.getByTestId(CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.businessCentral)).toBeOnTheScreen();
expect(screen.getByTestId('Campfire')).toBeOnTheScreen();
});
+
+ it.each([{enabledBetas: []}, {enabledBetas: [CONST.BETAS.EXACT_ONLINE]}])('offers the Exact Online waitlist with beta enrollment $enabledBetas', async ({enabledBetas}) => {
+ // Given an eligible workspace and a user who may have the future Exact connector beta
+ mockEnabledBetas = enabledBetas;
+ mockRouteParams = {};
+
+ // When the Accounting page lists its integrations
+ render(, {wrapper: AccountingTestWrapper});
+ await waitForBatchedUpdates();
+
+ // Then Exact Online offers the waitlist without starting an unimplemented connector flow
+ expect(screen.getByText('Exact Online')).toBeOnTheScreen();
+ expect(screen.getByRole('button', {name: 'Join waitlist'})).toBeOnTheScreen();
+ expect(mockStartIntegrationFlow).not.toHaveBeenCalled();
+ expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen();
+
+ await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, '2026-10-15 10:00:00'));
+ expect(screen.getByLabelText('Exact Online, Coming soon, You’re on the waitlist')).toBeOnTheScreen();
+
+ await act(() => Onyx.set(ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, null));
+ expect(screen.getByLabelText('Exact Online, Coming soon')).toBeOnTheScreen();
+ });
});
diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts
index bdc53ce368dd..d4f06c0af32a 100644
--- a/tests/unit/useAccountingConnectionListingsTest.ts
+++ b/tests/unit/useAccountingConnectionListingsTest.ts
@@ -57,7 +57,7 @@ describe('useAccountingConnectionListings', () => {
const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO);
// When the admin connects Xero
- xero?.onConnect();
+ xero?.onConnect?.();
// Then Xero's flow starts with nothing to disconnect
expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined});
@@ -69,7 +69,7 @@ describe('useAccountingConnectionListings', () => {
const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO);
// When the admin connects Xero
- xero?.onConnect();
+ xero?.onConnect?.();
// Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it
expect(mockStartIntegrationFlow).toHaveBeenCalledWith({
@@ -86,7 +86,7 @@ describe('useAccountingConnectionListings', () => {
const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO);
// When they try to connect Xero
- xero?.onConnect();
+ xero?.onConnect?.();
// Then they are told they can't, and no flow starts
expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1);
diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts
index 29c2d0e01c55..21118031e957 100644
--- a/tests/unit/useMCPConnectionListingsTest.ts
+++ b/tests/unit/useMCPConnectionListingsTest.ts
@@ -33,7 +33,7 @@ describe('useMCPConnectionListings', () => {
const claude = result.current.find((listing) => listing.key === 'claude');
// When they connect Claude
- claude?.onConnect();
+ claude?.onConnect?.();
// Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle
expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED);
@@ -46,7 +46,7 @@ describe('useMCPConnectionListings', () => {
const mcpGuide = result.current.find((listing) => listing.key === 'mcp');
// When the admin presses "+" on it
- mcpGuide?.onConnect();
+ mcpGuide?.onConnect?.();
// Then the guide opens, since the client is set up by following it rather than by connecting from here
expect(openExternalLink).toHaveBeenCalledWith(CONST.MCP_HELP_URL);
@@ -59,7 +59,7 @@ describe('useMCPConnectionListings', () => {
const {result} = renderHook(() => useMCPConnectionListings(policy));
// When they try to connect an assistant
- result.current.at(0)?.onConnect();
+ result.current.at(0)?.onConnect?.();
// Then they are told they can't, and nothing is turned on or opened
expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1);
diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts
index 653a95419651..5fd63941924c 100644
--- a/tests/unit/useMergeConnectionListingsTest.ts
+++ b/tests/unit/useMergeConnectionListingsTest.ts
@@ -63,7 +63,7 @@ describe('useMergeConnectionListings', () => {
const {listings, onStartSetup} = renderListings();
// When the admin connects an HR provider
- listings.at(0)?.onConnect();
+ listings.at(0)?.onConnect?.();
// Then the upgrade page opens instead of the provider's setup
expect(Navigation.navigate).toHaveBeenCalledTimes(1);
@@ -75,7 +75,7 @@ describe('useMergeConnectionListings', () => {
const {listings, onStartSetup} = renderListings();
// When the admin connects Gusto
- listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect();
+ listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect?.();
// Then HR is turned on and Gusto's setup starts, with nothing to replace
expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED);
@@ -91,7 +91,7 @@ describe('useMergeConnectionListings', () => {
// When the admin connects another provider and cancels the Replace prompt
await act(async () => {
- listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect();
+ listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect?.();
await waitForBatchedUpdates();
});
diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts
index b8db5cebbc8e..e00bfe80f14d 100644
--- a/tests/unit/useReceiptPartnerConnectionListingsTest.ts
+++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts
@@ -58,7 +58,7 @@ describe('useReceiptPartnerConnectionListings', () => {
const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy));
// When the admin presses "+" on Uber
- act(() => result.current.at(0)?.onConnect());
+ act(() => result.current.at(0)?.onConnect?.());
// Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature
expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED);
@@ -77,7 +77,7 @@ describe('useReceiptPartnerConnectionListings', () => {
// When the admin then connects Uber from the page and the connection shows up
mockUberState = {...mockUberState, isUberConnected: false};
rerender({});
- act(() => result.current.at(0)?.onConnect());
+ act(() => result.current.at(0)?.onConnect?.());
mockUberState = {...mockUberState, isUberConnected: true};
rerender({});