diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..188561d
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,3 @@
+#!/usr/bin/env sh
+
+npm run precommit
diff --git a/api-sync-status-preview.html b/api-sync-status-preview.html
new file mode 100644
index 0000000..914a7e2
--- /dev/null
+++ b/api-sync-status-preview.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ ApiSyncStatus Preview
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 2ac7e17..3cfb0ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,6 +36,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.3.0",
+ "husky": "^9.1.7",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.5.3",
@@ -7621,6 +7622,22 @@
"node": ">= 14"
}
},
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
diff --git a/package.json b/package.json
index 7916110..cd69975 100644
--- a/package.json
+++ b/package.json
@@ -20,8 +20,12 @@
"test:run": "vitest run",
"test:watch": "vitest --watch",
"test:coverage": "vitest run --coverage",
+ "format:check": "prettier --check .",
+ "format:check:staged": "node scripts/check-staged-format.js",
"lint": "eslint .",
- "lint:fix": "eslint . --fix"
+ "lint:fix": "eslint . --fix",
+ "precommit": "npm run lint && npm run format:check:staged",
+ "prepare": "husky"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
@@ -51,6 +55,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.3.0",
+ "husky": "^9.1.7",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.5.3",
diff --git a/scripts/check-staged-format.js b/scripts/check-staged-format.js
new file mode 100644
index 0000000..a954a98
--- /dev/null
+++ b/scripts/check-staged-format.js
@@ -0,0 +1,37 @@
+/* eslint-disable import/no-nodejs-modules */
+import { execFileSync } from "child_process";
+import { resolve } from "path";
+
+const prettierCliPath = resolve(
+ "node_modules",
+ "prettier",
+ "bin",
+ "prettier.cjs",
+);
+
+function getStagedFiles() {
+ const output = execFileSync(
+ "git",
+ ["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
+ { encoding: "utf8" },
+ ).trim();
+
+ if (!output) {
+ return [];
+ }
+
+ return output.split(/\r?\n/).filter(Boolean);
+}
+
+const stagedFiles = getStagedFiles();
+
+if (stagedFiles.length === 0) {
+ console.log("No staged files to check with Prettier.");
+ globalThis.process.exit(0);
+}
+
+execFileSync(
+ globalThis.process.execPath,
+ [prettierCliPath, "--check", "--ignore-unknown", ...stagedFiles],
+ { stdio: "inherit" },
+);
diff --git a/src/ReviewPage.jsx b/src/ReviewPage.jsx
index 1107eaa..3012d6a 100644
--- a/src/ReviewPage.jsx
+++ b/src/ReviewPage.jsx
@@ -1,201 +1,40 @@
import PropTypes from "prop-types";
-import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import {
- addSettleUpTransaction,
- fetchSettleUpPermissions,
-} from "./api/settleup";
import { useAppContext } from "./AppContext.jsx";
import { useAuth } from "./AuthProvider.jsx";
+import ApiSyncStatus from "./components/ApiSyncStatus.jsx";
import CenteredCardLayout from "./components/CenteredCardLayout.jsx";
import ReviewSection from "./components/ReviewSection.jsx";
-import { BOURSO_TRANSFER_PAYEE_ID } from "./constants.js";
+import { API_STATUS, useApiSync } from "./hooks/useApiSync";
import { formStatePropType } from "./propTypes.js";
-import { formatYYYYMMDDLocal } from "./utils/dateUtils";
-import { getAccountIdByName } from "./utils/ynabUtils";
export default function ReviewPage({ formState, onBack, onSubmitted }) {
const navigate = useNavigate();
- const [loading, setLoading] = useState(false);
- const [result, setResult] = useState("");
const { ynabAPI, budgetId, accounts } = useAppContext();
const { token, user } = useAuth();
- // Helper function to create base transaction object
- function createBaseTransaction(accountId, amount) {
- return {
- account_id: accountId,
- date: formatYYYYMMDDLocal(formState.date),
- amount: amount,
- payee_id: formState.payeeId || null,
- payee_name: !formState.payeeId ? formState.payee : undefined,
- category_id: formState.categoryId,
- memo: formState.description,
- approved: true,
- };
- }
-
- // Helper function to execute YNAB API call
- async function executeYnabTransaction(transaction, successMessage) {
- try {
- setLoading(true);
- const res = await ynabAPI.transactions.createTransaction(budgetId, {
- transaction,
- });
- setResult(successMessage + "\n" + JSON.stringify(res, null, 2));
- } catch (err) {
- setResult("YNAB API error: " + (err?.message || err));
- } finally {
- setLoading(false);
- }
- }
-
- async function handleYnabSubmit() {
- if (!ynabAPI || !budgetId) {
- setResult("YNAB not configured.");
- return;
- }
-
- // Split transaction logic
- if (formState.account.swile && formState.account.bourso) {
- const swileAccountId = getAccountIdByName(accounts, "Swile");
- const boursoAccountId = getAccountIdByName(accounts, "Boursorama");
-
- if (!swileAccountId || !boursoAccountId) {
- setResult("No matching YNAB account found for Swile or Bourso.");
- return;
- }
+ const { ynab, settleup, anyInFlight, startSync, retryFetch, retryEnter } =
+ useApiSync({ ynabAPI, budgetId, accounts, token, user });
- const transferInflowMilliunits =
- formState.swileMilliunits - formState.amountMilliunits;
+ async function handleConfirmSubmit() {
+ const results = await startSync(formState);
- // If Swile covers the full amount, create a simple transaction
- if (transferInflowMilliunits === 0) {
- const transaction = createBaseTransaction(
- swileAccountId,
- formState.amountMilliunits,
- );
- await executeYnabTransaction(transaction, "✅ YNAB transaction sent!");
- return;
- }
+ const allTargetedSucceeded =
+ (!formState.target.ynab || results.ynab === API_STATUS.SUCCESS) &&
+ (!formState.target.settleup || results.settleup === API_STATUS.SUCCESS);
- // Create split transaction
- const transaction = {
- ...createBaseTransaction(swileAccountId, formState.swileMilliunits),
- category_id: null, // Override for split transactions
- subtransactions: [
- {
- amount: formState.amountMilliunits,
- category_id: formState.categoryId,
- memo: formState.description,
- payee_id: formState.payeeId || null,
- },
- {
- amount: transferInflowMilliunits,
- payee_id: BOURSO_TRANSFER_PAYEE_ID,
- transfer_account_id: boursoAccountId,
- memo: "Bourso completion",
- },
- ],
- };
-
- await executeYnabTransaction(
- transaction,
- "✅ YNAB split transaction sent!",
- );
- return;
- }
-
- // Single-account transaction
- const accountId = formState.account.bourso
- ? getAccountIdByName(accounts, "Boursorama")
- : formState.account.swile
- ? getAccountIdByName(accounts, "Swile")
- : getAccountIdByName(accounts, "Boursorama"); // Default fallback
-
- if (!accountId) {
- setResult("No matching YNAB account found for the selected button.");
- return;
- }
-
- const transaction = createBaseTransaction(
- accountId,
- formState.amountMilliunits,
- );
- await executeYnabTransaction(transaction, "✅ YNAB transaction sent!");
+ if (allTargetedSucceeded && onSubmitted) onSubmitted();
}
- async function handleSettleUpSubmit() {
- setResult("");
- if (
- !token ||
- !formState.settleUpGroup?.groupId ||
- formState.amountMilliunits === 0 ||
- !formState.settleUpPayerId ||
- !formState.settleUpMembers?.length
- ) {
- console.warn("[ReviewPage] Missing required fields for SettleUp submit");
- setResult("❌ Please fill all required fields before submitting.");
- return;
- }
- const permissions = await fetchSettleUpPermissions(
- token,
- formState.settleUpGroup.groupId,
- );
- if (!permissions[user.uid] || permissions[user.uid].level < 20) {
- console.warn("[ReviewPage] Insufficient permissions for user:", user.uid);
- setResult("❌ You do not have permission to submit this transaction.");
- return;
- }
- const amount = (-formState.amountMilliunits / 1000).toFixed(2);
- const tx = {
- category:
- formState.settleUpCategory === "∅"
- ? undefined
- : formState.settleUpCategory,
- currencyCode: formState.settleUpCurrency || "EUR",
- dateTime: formState.date.getTime(),
- items: [
- {
- amount: amount,
- forWhom: formState.settleUpMembers.map((member) => ({
- memberId: member.id,
- weight: (member.defaultWeight || "1").toString(),
- })),
- },
- ],
- purpose:
- formState.payee +
- (formState.description ? ` - ${formState.description}` : ""),
- type: "expense",
- whoPaid: [{ memberId: formState.settleUpPayerId, weight: "1" }],
- exchangeRates: [],
- fixedExchangeRate: true,
- };
- try {
- setLoading(true);
- const data = await addSettleUpTransaction(
- token,
- formState.settleUpGroup.groupId,
- tx,
- );
- if (data && data.name) {
- setResult("✅ SettleUp transaction sent!");
- } else {
- setResult("Error adding transaction: " + JSON.stringify(data));
- }
- } catch (e) {
- setResult("Error adding transaction: " + e.message);
- } finally {
- setLoading(false);
- }
+ function handleRetryYnab() {
+ if (ynab.status === API_STATUS.FETCH_ERROR) retryFetch("ynab");
+ else retryEnter("ynab");
}
- async function handleSubmit() {
- if (formState.target.ynab) await handleYnabSubmit();
- if (formState.target.settleup) await handleSettleUpSubmit();
- if (onSubmitted) onSubmitted();
+ function handleRetrySettleup() {
+ if (settleup.status === API_STATUS.FETCH_ERROR) retryFetch("settleup");
+ else retryEnter("settleup");
}
return (
@@ -212,20 +51,18 @@ export default function ReviewPage({ formState, onBack, onSubmitted }) {
- {result && (
-
- {result}
-
- )}
+
);
diff --git a/src/api-sync-status-preview.jsx b/src/api-sync-status-preview.jsx
new file mode 100644
index 0000000..c26774e
--- /dev/null
+++ b/src/api-sync-status-preview.jsx
@@ -0,0 +1,151 @@
+import PropTypes from "prop-types";
+import { StrictMode } from "react";
+import ReactDOM from "react-dom/client";
+
+import ApiSyncStatus from "./components/ApiSyncStatus.jsx";
+import { API_STATUS } from "./hooks/useApiSync";
+import "./index.css";
+
+const SAMPLE_STATES = [
+ {
+ title: "Idle",
+ ynab: { status: API_STATUS.IDLE, error: null },
+ settleup: { status: API_STATUS.IDLE, error: null },
+ },
+ {
+ title: "Fetching",
+ ynab: { status: API_STATUS.FETCHING, error: null },
+ settleup: { status: API_STATUS.FETCHING, error: null },
+ },
+ {
+ title: "Mixed",
+ ynab: { status: API_STATUS.READY, error: null },
+ settleup: { status: API_STATUS.FETCHING, error: null },
+ },
+ {
+ title: "Enter error",
+ ynab: { status: API_STATUS.SUCCESS, error: null },
+ settleup: {
+ status: API_STATUS.ENTER_ERROR,
+ error: "Temporary network issue",
+ },
+ },
+ {
+ title: "Fetch error",
+ ynab: { status: API_STATUS.FETCH_ERROR, error: "YNAB auth expired" },
+ settleup: { status: API_STATUS.SUCCESS, error: null },
+ },
+ {
+ title: "Success",
+ ynab: { status: API_STATUS.SUCCESS, error: null },
+ settleup: { status: API_STATUS.SUCCESS, error: null },
+ },
+];
+
+function PreviewCard({ title, ynab, settleup }) {
+ return (
+
+
+ {title}
+
+ {}}
+ onRetrySettleup={() => {}}
+ />
+
+ );
+}
+
+PreviewCard.propTypes = {
+ title: PropTypes.string.isRequired,
+ ynab: PropTypes.shape({
+ status: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ }).isRequired,
+ settleup: PropTypes.shape({
+ status: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ }).isRequired,
+};
+
+function App() {
+ return (
+
+
+
+
+ Preview
+
+
+ ApiSyncStatus
+
+
+ Static preview of the inline status component with the current brand
+ colors, spinner, success, and error states.
+
+
+
+
+ {SAMPLE_STATES.map((sample) => (
+
+ ))}
+
+
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+ ,
+);
diff --git a/src/components/ApiSyncStatus.jsx b/src/components/ApiSyncStatus.jsx
new file mode 100644
index 0000000..d685f72
--- /dev/null
+++ b/src/components/ApiSyncStatus.jsx
@@ -0,0 +1,187 @@
+import PropTypes from "prop-types";
+import { MdCheck, MdClose, MdHourglassEmpty } from "react-icons/md";
+
+import { API_STATUS } from "../hooks/useApiSync";
+
+// ─── Brand colors ──────────────────────────────────────────────────────────────
+
+const YNAB_COLOR = "#5C6CFA";
+const SETTLEUP_COLOR = "#f2774a";
+
+// ─── Inline spinner SVG ────────────────────────────────────────────────────────
+
+function Spinner({ color }) {
+ return (
+
+ );
+}
+
+Spinner.propTypes = {
+ color: PropTypes.string.isRequired,
+};
+
+// ─── Status icon ───────────────────────────────────────────────────────────────
+
+function StatusIcon({ status, color }) {
+ const style = { verticalAlign: "middle", color };
+ if (status === API_STATUS.FETCHING || status === API_STATUS.ENTERING) {
+ return ;
+ }
+ if (status === API_STATUS.SUCCESS) return ;
+ if (status === API_STATUS.FETCH_ERROR || status === API_STATUS.ENTER_ERROR) {
+ return ;
+ }
+ if (status === API_STATUS.READY) return ;
+ return null;
+}
+
+StatusIcon.propTypes = {
+ status: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+};
+
+// ─── Status labels ─────────────────────────────────────────────────────────────
+
+const LABELS = {
+ [API_STATUS.FETCHING]: "fetching data…",
+ [API_STATUS.FETCH_ERROR]: "failed to fetch",
+ [API_STATUS.READY]: "ready",
+ [API_STATUS.ENTERING]: "submitting…",
+ [API_STATUS.SUCCESS]: "done",
+ [API_STATUS.ENTER_ERROR]: "submission failed",
+};
+
+// ─── Single API chip ───────────────────────────────────────────────────────────
+
+function ApiChip({ name, syncState, color, onRetry }) {
+ const { status, error } = syncState;
+ const label = LABELS[status];
+ if (!label) return null; // idle or skipped → hidden
+
+ const canRetry =
+ status === API_STATUS.FETCH_ERROR || status === API_STATUS.ENTER_ERROR;
+
+ return (
+
+
+
+ {name} {label}
+
+ {canRetry && (
+
+ )}
+
+ );
+}
+
+ApiChip.propTypes = {
+ name: PropTypes.string.isRequired,
+ syncState: PropTypes.shape({
+ status: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ }).isRequired,
+ color: PropTypes.string.isRequired,
+ onRetry: PropTypes.func.isRequired,
+};
+
+// ─── Main component ────────────────────────────────────────────────────────────
+
+/**
+ * Compact inline dual-API status summary.
+ * Renders nothing when both APIs are idle/skipped.
+ *
+ * Example: [spinner] YNAB submitting… | [check] SettleUp done
+ */
+export default function ApiSyncStatus({
+ ynab,
+ settleup,
+ onRetryYnab,
+ onRetrySettleup,
+}) {
+ const showYnab =
+ ynab.status !== API_STATUS.IDLE && ynab.status !== API_STATUS.SKIPPED;
+ const showSettleup =
+ settleup.status !== API_STATUS.IDLE &&
+ settleup.status !== API_STATUS.SKIPPED;
+
+ if (!showYnab && !showSettleup) return null;
+
+ return (
+
+ {showYnab && (
+
+ )}
+ {showYnab && showSettleup && (
+
+ |
+
+ )}
+ {showSettleup && (
+
+ )}
+
+ );
+}
+
+ApiSyncStatus.propTypes = {
+ ynab: PropTypes.shape({
+ status: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ }).isRequired,
+ settleup: PropTypes.shape({
+ status: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ }).isRequired,
+ onRetryYnab: PropTypes.func.isRequired,
+ onRetrySettleup: PropTypes.func.isRequired,
+};
diff --git a/src/components/ApiSyncStatus.test.jsx b/src/components/ApiSyncStatus.test.jsx
new file mode 100644
index 0000000..e75b9c5
--- /dev/null
+++ b/src/components/ApiSyncStatus.test.jsx
@@ -0,0 +1,70 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { API_STATUS } from "../hooks/useApiSync";
+
+import ApiSyncStatus from "./ApiSyncStatus.jsx";
+
+function renderStatus(overrides = {}) {
+ const onRetryYnab = vi.fn();
+ const onRetrySettleup = vi.fn();
+
+ render(
+ ,
+ );
+
+ return { onRetryYnab, onRetrySettleup };
+}
+
+describe("ApiSyncStatus", () => {
+ it("renders nothing when both APIs are idle", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders the current status labels with the API names", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/YNAB fetching data/i)).toBeInTheDocument();
+ expect(screen.getByText(/SettleUp ready/i)).toBeInTheDocument();
+ });
+
+ it("renders retry buttons for error states and disables them immediately on click", () => {
+ const { onRetrySettleup } = renderStatus({
+ ynab: { status: API_STATUS.SUCCESS, error: null },
+ settleup: {
+ status: API_STATUS.ENTER_ERROR,
+ error: "Temporary network issue",
+ },
+ });
+
+ const retryButton = screen.getByRole("button", { name: /retry/i });
+ expect(retryButton).toHaveAttribute("type", "button");
+
+ fireEvent.click(retryButton);
+
+ expect(retryButton).toBeDisabled();
+ expect(onRetrySettleup).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/hooks/useApiSync.js b/src/hooks/useApiSync.js
new file mode 100644
index 0000000..92142d3
--- /dev/null
+++ b/src/hooks/useApiSync.js
@@ -0,0 +1,320 @@
+import { useState, useCallback, useRef } from "react";
+
+import {
+ addSettleUpTransaction,
+ fetchSettleUpPermissions,
+} from "../api/settleup";
+import { BOURSO_TRANSFER_PAYEE_ID } from "../constants";
+import { formatYYYYMMDDLocal } from "../utils/dateUtils";
+import { getAccountIdByName } from "../utils/ynabUtils";
+
+// ─── Status constants ──────────────────────────────────────────────────────────
+
+export const API_STATUS = {
+ IDLE: "idle",
+ SKIPPED: "skipped",
+ FETCHING: "fetching",
+ FETCH_ERROR: "fetch_error",
+ READY: "ready",
+ ENTERING: "entering",
+ SUCCESS: "success",
+ ENTER_ERROR: "enter_error",
+};
+
+const INITIAL = { status: API_STATUS.IDLE, error: null };
+
+// ─── YNAB helpers ──────────────────────────────────────────────────────────────
+
+function buildYnabTransaction(formState, accounts) {
+ const { swileMilliunits, amountMilliunits, account } = formState;
+
+ function base(accountId, amount) {
+ return {
+ account_id: accountId,
+ date: formatYYYYMMDDLocal(formState.date),
+ amount,
+ payee_id: formState.payeeId || null,
+ payee_name: !formState.payeeId ? formState.payee : undefined,
+ category_id: formState.categoryId,
+ memo: formState.description,
+ approved: true,
+ };
+ }
+
+ if (account.swile && account.bourso) {
+ const swileId = getAccountIdByName(accounts, "Swile");
+ const boursoId = getAccountIdByName(accounts, "Boursorama");
+ if (!swileId || !boursoId)
+ throw new Error("No matching YNAB account for Swile or Bourso.");
+
+ const transferInflow = swileMilliunits - amountMilliunits;
+ if (transferInflow === 0) {
+ return base(swileId, amountMilliunits);
+ }
+
+ return {
+ ...base(swileId, swileMilliunits),
+ category_id: null,
+ subtransactions: [
+ {
+ amount: amountMilliunits,
+ category_id: formState.categoryId,
+ memo: formState.description,
+ payee_id: formState.payeeId || null,
+ },
+ {
+ amount: transferInflow,
+ payee_id: BOURSO_TRANSFER_PAYEE_ID,
+ transfer_account_id: boursoId,
+ memo: "Bourso completion",
+ },
+ ],
+ };
+ }
+
+ const accountId = account.bourso
+ ? getAccountIdByName(accounts, "Boursorama")
+ : account.swile
+ ? getAccountIdByName(accounts, "Swile")
+ : getAccountIdByName(accounts, "Boursorama");
+
+ if (!accountId)
+ throw new Error("No matching YNAB account for the selected button.");
+ return base(accountId, amountMilliunits);
+}
+
+async function runYnabEnter(formState, { ynabAPI, budgetId, accounts }) {
+ if (!ynabAPI || !budgetId) throw new Error("YNAB not configured.");
+ const transaction = buildYnabTransaction(formState, accounts);
+ await ynabAPI.transactions.createTransaction(budgetId, { transaction });
+}
+
+// ─── SettleUp helpers ──────────────────────────────────────────────────────────
+
+async function fetchAndValidateSettleup(formState, { token, user }) {
+ const { settleUpGroup } = formState;
+ if (
+ !token ||
+ !settleUpGroup?.groupId ||
+ formState.amountMilliunits === 0 ||
+ !formState.settleUpPayerId ||
+ !formState.settleUpMembers?.length
+ ) {
+ throw new Error("Missing required fields for SettleUp.");
+ }
+ const permissions = await fetchSettleUpPermissions(
+ token,
+ settleUpGroup.groupId,
+ );
+ if (!permissions[user.uid] || permissions[user.uid].level < 20) {
+ throw new Error("Insufficient SettleUp permissions.");
+ }
+ return permissions;
+}
+
+async function runSettleupEnter(formState, { token }) {
+ const amount = (-formState.amountMilliunits / 1000).toFixed(2);
+ const tx = {
+ category:
+ formState.settleUpCategory === "∅"
+ ? undefined
+ : formState.settleUpCategory,
+ currencyCode: formState.settleUpCurrency || "EUR",
+ dateTime: formState.date.getTime(),
+ items: [
+ {
+ amount,
+ forWhom: formState.settleUpMembers.map((m) => ({
+ memberId: m.id,
+ weight: (m.defaultWeight || "1").toString(),
+ })),
+ },
+ ],
+ purpose:
+ formState.payee +
+ (formState.description ? ` - ${formState.description}` : ""),
+ type: "expense",
+ whoPaid: [{ memberId: formState.settleUpPayerId, weight: "1" }],
+ exchangeRates: [],
+ fixedExchangeRate: true,
+ };
+ const data = await addSettleUpTransaction(
+ token,
+ formState.settleUpGroup.groupId,
+ tx,
+ );
+ if (!data?.name)
+ throw new Error("Unexpected response: " + JSON.stringify(data));
+}
+
+// ─── Hook ──────────────────────────────────────────────────────────────────────
+
+/**
+ * Tracks and orchestrates API sync status for YNAB and SettleUp independently.
+ *
+ * Execution model: both APIs run concurrently. YNAB has no fetch phase and goes
+ * straight to entering. SettleUp fetches+validates permissions first.
+ *
+ * @param {{ ynabAPI, budgetId, accounts, token, user }} deps - Live context deps
+ */
+export function useApiSync({ ynabAPI, budgetId, accounts, token, user }) {
+ const [ynab, setYnab] = useState(INITIAL);
+ const [settleup, setSettleup] = useState(INITIAL);
+
+ // Persisted across retries without triggering re-renders
+ const lastFormStateRef = useRef(null);
+ const settleupPermissionsRef = useRef(null);
+
+ const anyInFlight =
+ ynab.status === API_STATUS.FETCHING ||
+ ynab.status === API_STATUS.ENTERING ||
+ settleup.status === API_STATUS.FETCHING ||
+ settleup.status === API_STATUS.ENTERING;
+
+ // ── Internal runners ──────────────────────────────────────────────────────
+
+ const _runYnabEnter = useCallback(
+ async (formState) => {
+ setYnab({ status: API_STATUS.ENTERING, error: null });
+ try {
+ await runYnabEnter(formState, { ynabAPI, budgetId, accounts });
+ setYnab({ status: API_STATUS.SUCCESS, error: null });
+ return API_STATUS.SUCCESS;
+ } catch (err) {
+ const msg = err?.message || String(err);
+ setYnab({ status: API_STATUS.ENTER_ERROR, error: msg });
+ return API_STATUS.ENTER_ERROR;
+ }
+ },
+ [ynabAPI, budgetId, accounts],
+ );
+
+ const _runSettleupFetchThenEnter = useCallback(
+ async (formState) => {
+ setSettleup({ status: API_STATUS.FETCHING, error: null });
+ let permissions;
+ try {
+ permissions = await fetchAndValidateSettleup(formState, {
+ token,
+ user,
+ });
+ settleupPermissionsRef.current = permissions;
+ setSettleup({ status: API_STATUS.READY, error: null });
+ } catch (err) {
+ const msg = err?.message || String(err);
+ setSettleup({ status: API_STATUS.FETCH_ERROR, error: msg });
+ return API_STATUS.FETCH_ERROR;
+ }
+ // Enter phase
+ setSettleup({ status: API_STATUS.ENTERING, error: null });
+ try {
+ await runSettleupEnter(formState, { token });
+ setSettleup({ status: API_STATUS.SUCCESS, error: null });
+ return API_STATUS.SUCCESS;
+ } catch (err) {
+ const msg = err?.message || String(err);
+ setSettleup({ status: API_STATUS.ENTER_ERROR, error: msg });
+ return API_STATUS.ENTER_ERROR;
+ }
+ },
+ [token, user],
+ );
+
+ const _runSettleupEnterOnly = useCallback(
+ async (formState) => {
+ const permissions = settleupPermissionsRef.current;
+ if (!permissions) {
+ // No cached permissions — fall back to full fetch+enter
+ return _runSettleupFetchThenEnter(formState);
+ }
+ setSettleup({ status: API_STATUS.ENTERING, error: null });
+ try {
+ await runSettleupEnter(formState, { token });
+ setSettleup({ status: API_STATUS.SUCCESS, error: null });
+ return API_STATUS.SUCCESS;
+ } catch (err) {
+ const msg = err?.message || String(err);
+ setSettleup({ status: API_STATUS.ENTER_ERROR, error: msg });
+ return API_STATUS.ENTER_ERROR;
+ }
+ },
+ [token, _runSettleupFetchThenEnter],
+ );
+
+ // ── Public API ─────────────────────────────────────────────────────────────
+
+ /**
+ * Start a full sync for all targeted APIs concurrently.
+ * Returns { ynab: finalStatus, settleup: finalStatus }.
+ */
+ const startSync = useCallback(
+ async (formState) => {
+ lastFormStateRef.current = formState;
+ settleupPermissionsRef.current = null;
+
+ const targets = formState.target;
+ setYnab(
+ targets.ynab
+ ? { status: API_STATUS.ENTERING, error: null }
+ : { status: API_STATUS.SKIPPED, error: null },
+ );
+ setSettleup(
+ targets.settleup
+ ? { status: API_STATUS.FETCHING, error: null }
+ : { status: API_STATUS.SKIPPED, error: null },
+ );
+
+ const [ynabResult, settleupResult] = await Promise.allSettled([
+ targets.ynab
+ ? _runYnabEnter(formState)
+ : Promise.resolve(API_STATUS.SKIPPED),
+ targets.settleup
+ ? _runSettleupFetchThenEnter(formState)
+ : Promise.resolve(API_STATUS.SKIPPED),
+ ]);
+
+ return {
+ ynab: ynabResult.value ?? API_STATUS.ENTER_ERROR,
+ settleup: settleupResult.value ?? API_STATUS.ENTER_ERROR,
+ };
+ },
+ [_runYnabEnter, _runSettleupFetchThenEnter],
+ );
+
+ /**
+ * Retry from the fetch phase (for FETCH_ERROR).
+ * For YNAB (no fetch phase), this is equivalent to retryEnter.
+ */
+ const retryFetch = useCallback(
+ async (api) => {
+ const formState = lastFormStateRef.current;
+ if (!formState) return;
+ if (api === "ynab") return _runYnabEnter(formState);
+ if (api === "settleup") return _runSettleupFetchThenEnter(formState);
+ },
+ [_runYnabEnter, _runSettleupFetchThenEnter],
+ );
+
+ /**
+ * Retry from the enter phase only (for ENTER_ERROR).
+ * Reuses cached permissions for SettleUp to avoid re-fetching.
+ */
+ const retryEnter = useCallback(
+ async (api) => {
+ const formState = lastFormStateRef.current;
+ if (!formState) return;
+ if (api === "ynab") return _runYnabEnter(formState);
+ if (api === "settleup") return _runSettleupEnterOnly(formState);
+ },
+ [_runYnabEnter, _runSettleupEnterOnly],
+ );
+
+ return {
+ ynab,
+ settleup,
+ anyInFlight,
+ startSync,
+ retryFetch,
+ retryEnter,
+ };
+}
diff --git a/src/hooks/useApiSync.test.js b/src/hooks/useApiSync.test.js
new file mode 100644
index 0000000..efab04c
--- /dev/null
+++ b/src/hooks/useApiSync.test.js
@@ -0,0 +1,70 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { API_STATUS, useApiSync } from "./useApiSync";
+
+const { createTransaction, fetchSettleUpPermissions, addSettleUpTransaction } =
+ vi.hoisted(() => ({
+ createTransaction: vi.fn(),
+ fetchSettleUpPermissions: vi.fn(),
+ addSettleUpTransaction: vi.fn(),
+ }));
+
+vi.mock("../api/settleup", () => ({
+ fetchSettleUpPermissions: (...args) => fetchSettleUpPermissions(...args),
+ addSettleUpTransaction: (...args) => addSettleUpTransaction(...args),
+}));
+
+describe("useApiSync", () => {
+ const formState = {
+ target: { ynab: true, settleup: true },
+ date: new Date("2026-07-18T12:00:00.000Z"),
+ payeeId: "",
+ payee: "Lunch",
+ categoryId: "cat-1",
+ description: "Team lunch",
+ account: { swile: false, bourso: true },
+ swileMilliunits: 20000,
+ amountMilliunits: 10000,
+ settleUpGroup: { groupId: "group-1" },
+ settleUpPayerId: "member-1",
+ settleUpMembers: [{ id: "member-1", defaultWeight: "1" }],
+ settleUpCategory: "∅",
+ settleUpCurrency: "EUR",
+ };
+
+ const ynabAPI = {
+ transactions: {
+ createTransaction,
+ },
+ };
+
+ beforeEach(() => {
+ createTransaction.mockImplementation(() => new Promise(() => {}));
+ fetchSettleUpPermissions.mockImplementation(() => new Promise(() => {}));
+ addSettleUpTransaction.mockImplementation(() => new Promise(() => {}));
+ });
+
+ it("marks both APIs in-flight immediately when syncing starts", async () => {
+ const { result } = renderHook(() =>
+ useApiSync({
+ ynabAPI,
+ budgetId: "budget-1",
+ accounts: [
+ { id: "acc-1", name: "Boursorama", closed: false },
+ { id: "acc-2", name: "Swile", closed: false },
+ ],
+ token: "token-1",
+ user: { uid: "user-1" },
+ }),
+ );
+
+ await act(async () => {
+ void result.current.startSync(formState);
+ });
+
+ expect(result.current.ynab.status).toBe(API_STATUS.ENTERING);
+ expect(result.current.settleup.status).toBe(API_STATUS.FETCHING);
+ expect(result.current.anyInFlight).toBe(true);
+ });
+});
diff --git a/src/version.js b/src/version.js
index c12dbe1..4d4682e 100644
--- a/src/version.js
+++ b/src/version.js
@@ -2,13 +2,13 @@
// Do not edit manually
export const VERSION_INFO = {
- "tag": null,
- "branch": "copilot/update-icons-to-loading-svg",
- "commit": "293c8f3",
+ "tag": "1.2.0",
+ "branch": "feature/simplified-api-status-tracking",
+ "commit": "db79089",
"isDirty": true,
- "buildTime": "2025-10-26T11:09:42.757Z",
- "version": "copilot/update-icons-to-loading-svg-293c8f3-dirty",
- "shortVersion": "293c8f3"
+ "buildTime": "2026-07-17T21:57:22.329Z",
+ "version": "1.2.0",
+ "shortVersion": "1.2.0"
};
export const getVersionString = () => {