Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 55 additions & 38 deletions app/features/accounts/account-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { AgicashDbAccountWithProofs } from '../agicash-db/database';
import { useUser } from '../user/user-hooks';
import {
type Account,
type AccountPurpose,
type AccountType,
type CashuAccount,
type ExtendedAccount,
Expand Down Expand Up @@ -140,23 +141,54 @@ export const accountsQueryOptions = ({
});
};

export function useAccounts<T extends AccountType = AccountType>(select?: {
currency?: Currency;
type?: T;
isOnline?: boolean;
excludeClosedLoopAccounts?: boolean;
onlyIncludeClosedLoopAccounts?: boolean;
}): UseSuspenseQueryResult<ExtendedAccount<T>[]> {
/**
* Filter options for `useAccounts` hook.
* Results are sorted by creation date (oldest first).
*/
type UseAccountsSelect<
T extends AccountType = AccountType,
P extends AccountPurpose = AccountPurpose,
> = P extends 'gift-card'
? {
/** Filter by currency (e.g., 'BTC', 'USD') */
currency?: Currency;
/** Must be 'cashu' when purpose is 'gift-card'. */
type?: 'cashu';
/** Filter by online status */
isOnline?: boolean;
/** Filter for gift-card accounts. Returns `CashuAccount[]` since gift cards are always cashu. */
purpose: P;
}
: {
/** Filter by currency (e.g., 'BTC', 'USD') */
currency?: Currency;
/** Filter by account type ('cashu' | 'spark'). Narrows the return type. */
type?: T;
/** Filter by online status */
isOnline?: boolean;
/** Filter by purpose. When omitted or 'transactional', any account type is allowed. */
purpose?: P;
};

export function useAccounts(
select: UseAccountsSelect<'cashu', 'gift-card'>,
): UseSuspenseQueryResult<ExtendedAccount<'cashu'>[]>;
export function useAccounts<
T extends AccountType = AccountType,
P extends AccountPurpose = AccountPurpose,
>(
select?: UseAccountsSelect<T, P>,
): UseSuspenseQueryResult<ExtendedAccount<T>[]>;
export function useAccounts<
T extends AccountType = AccountType,
P extends AccountPurpose = AccountPurpose,
>(
select?: UseAccountsSelect<T, P>,
): UseSuspenseQueryResult<ExtendedAccount<T>[]> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if we decide to keep this approach I think it would be less confusing to do it like this:

export function useAccounts(
  select: UseAccountsSelect<'cashu', 'gift-card'>,
): UseSuspenseQueryResult<ExtendedAccount<'cashu'>[]>;
export function useAccounts<
  T extends AccountType = AccountType,
  P extends AccountPurpose = AccountPurpose,
>(
  select?: UseAccountsSelect<T, P>,
): UseSuspenseQueryResult<ExtendedAccount<T>[]>;
export function useAccounts<
  T extends AccountType = AccountType,
  P extends AccountPurpose = AccountPurpose,
>(
  select?: UseAccountsSelect<T, P>,
): UseSuspenseQueryResult<ExtendedAccount<T>[]> {
...

this way you actually utilise the second generic param instead of having it but using & { purpose: 'gift-card' }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh yea nice, that was left over from a previous attempt before doing these overloads. Fixed

const user = useUser();
const accountRepository = useAccountRepository();

const {
currency,
type,
isOnline,
excludeClosedLoopAccounts,
onlyIncludeClosedLoopAccounts,
} = select ?? {};
const { currency, type, isOnline, purpose } = select ?? {};

return useSuspenseQuery({
...accountsQueryOptions({ userId: user.id, accountRepository }),
Expand All @@ -166,13 +198,7 @@ export function useAccounts<T extends AccountType = AccountType>(select?: {
(data: Account[]) => {
const extendedData = AccountService.getExtendedAccounts(user, data);

if (
!currency &&
!type &&
isOnline === undefined &&
!excludeClosedLoopAccounts &&
!onlyIncludeClosedLoopAccounts
) {
if (!currency && !type && isOnline === undefined && !purpose) {
return extendedData
.slice()
.sort(
Expand All @@ -193,13 +219,8 @@ export function useAccounts<T extends AccountType = AccountType>(select?: {
if (isOnline !== undefined && account.isOnline !== isOnline) {
return false;
}
if (account.type === 'cashu') {
if (excludeClosedLoopAccounts) {
return !account.wallet.isClosedLoop;
}
if (onlyIncludeClosedLoopAccounts) {
return account.wallet.isClosedLoop;
}
if (purpose && account.purpose !== purpose) {
return false;
}
return true;
},
Expand All @@ -212,14 +233,7 @@ export function useAccounts<T extends AccountType = AccountType>(select?: {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
},
[
currency,
type,
isOnline,
excludeClosedLoopAccounts,
onlyIncludeClosedLoopAccounts,
user,
],
[currency, type, isOnline, purpose, user],
),
});
}
Expand Down Expand Up @@ -358,11 +372,14 @@ export function useAddCashuAccount() {
}

/**
* Hook to get the sum of all account balances for a given currency.
* Hook to get the sum of all transactional account balances for a given currency.
* Null balances are ignored.
*/
export function useBalance(currency: Currency) {
const { data: accounts } = useAccounts({ currency });
const { data: accounts } = useAccounts({
currency,
purpose: 'transactional',
});
const balance = accounts.reduce((acc, account) => {
const accountBalance = getAccountBalance(account);
return accountBalance !== null ? acc.add(accountBalance) : acc;
Expand Down
12 changes: 8 additions & 4 deletions app/features/accounts/account-icons.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { LandmarkIcon } from 'lucide-react';
import { GiftIcon, LandmarkIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { SparkIcon as SparkIconSvg } from '~/components/spark-icon';
import type { AccountType } from './account';
import type { Account, AccountType } from './account';

const CashuIcon = () => <LandmarkIcon className="h-4 w-4" />;
const SparkIcon = () => <SparkIconSvg className="h-4 w-4" />;
const GiftCardIcon = () => <GiftIcon className="h-4 w-4" />;

const iconsByAccountType: Record<AccountType, ReactNode> = {
cashu: <CashuIcon />,
spark: <SparkIcon />,
};

export function AccountTypeIcon({ type }: { type: AccountType }) {
return iconsByAccountType[type];
export function AccountIcon({ account }: { account: Account }) {
if (account.purpose === 'gift-card') {
return <GiftCardIcon />;
}
return iconsByAccountType[account.type];
}
2 changes: 2 additions & 0 deletions app/features/accounts/account-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class AccountRepository {
currency: accountInput.currency,
details,
user_id: accountInput.userId,
purpose: accountInput.purpose,
};

const query = this.db
Expand Down Expand Up @@ -160,6 +161,7 @@ export class AccountRepository {
id: data.id,
name: data.name,
currency: data.currency as Currency,
purpose: data.purpose,
createdAt: data.created_at,
version: data.version,
};
Expand Down
4 changes: 2 additions & 2 deletions app/features/accounts/account-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ScrollArea } from '~/components/ui/scroll-area';
import { cn } from '~/lib/utils';
import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount';
import { type Account, getAccountBalance } from './account';
import { AccountTypeIcon } from './account-icons';
import { AccountIcon } from './account-icons';
import { BalanceOfflineHoverCard } from './balance-offline-hover-card';

export type AccountSelectorOption<T extends Account = Account> = T & {
Expand Down Expand Up @@ -46,7 +46,7 @@ function AccountItem({ account }: { account: AccountSelectorOption }) {

return (
<div className="flex w-full items-center gap-4 px-3 py-4">
<AccountTypeIcon type={account.type} />
<AccountIcon account={account} />
<div className="flex w-full flex-col justify-between gap-2 text-start">
<span className="font-medium">{account.name}</span>
<div className="flex items-center justify-between text-xs">
Expand Down
27 changes: 27 additions & 0 deletions app/features/accounts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import { type Currency, Money } from '~/lib/money';

export type AccountType = 'cashu' | 'spark';

/**
* The purpose of this account.
* - 'transactional': Regular accounts for sending/receiving payments
* - 'gift-card': Closed-loop accounts for mints that are issuing gift cards
*/
export type AccountPurpose = 'transactional' | 'gift-card';

export type CashuProof = {
id: string;
accountId: string;
Expand Down Expand Up @@ -41,6 +48,7 @@ export type Account = {
id: string;
name: string;
type: AccountType;
purpose: AccountPurpose;
isOnline: boolean;
currency: Currency;
createdAt: string;
Expand Down Expand Up @@ -87,6 +95,25 @@ export type SparkAccount = Extract<Account, { type: 'spark' }>;
export type ExtendedCashuAccount = ExtendedAccount<'cashu'>;
export type ExtendedSparkAccount = ExtendedAccount<'spark'>;

/**
* Returns true if the account can send payments through the Lightning network.
* Returns false for test mints and gift-card accounts.
*/
export const canSendToLightning = (account: Account): boolean => {
if (account.type === 'spark') {
return true;
}
return !account.isTestMint && account.purpose === 'transactional';
};

/**
* Returns true if the account can receive payments via the Lightning network.
* Returns false for test mints only.
*/
export const canReceiveFromLightning = (account: Account): boolean => {
return account.type === 'spark' || !account.isTestMint;
};

export const getAccountBalance = (account: Account) => {
if (account.type === 'cashu') {
const value = sumProofs(account.proofs);
Expand Down
27 changes: 23 additions & 4 deletions app/features/gift-cards/add-gift-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,28 @@ import {
} from '~/components/wallet-card';
import { useAddCashuAccount } from '~/features/accounts/account-hooks';
import { useToast } from '~/hooks/use-toast';
import type { Currency } from '~/lib/money';
import type { GiftCardInfo } from './use-discover-cards';

type AddGiftCardParams = {
name: string;
currency: Currency;
url: string;
};

function useAddGiftCard() {
const addCashuAccount = useAddCashuAccount();

return ({ name, currency, url }: AddGiftCardParams) =>
addCashuAccount({
name,
currency,
mintUrl: url,
type: 'cashu',
purpose: 'gift-card',
});
}

type AddGiftCardProps = {
giftCard: GiftCardInfo;
};
Expand All @@ -32,7 +52,7 @@ type AddGiftCardProps = {
*/
export function AddGiftCard({ giftCard }: AddGiftCardProps) {
const [isAdding, setIsAdding] = useState(false);
const addAccount = useAddCashuAccount();
const addGiftCard = useAddGiftCard();
const navigate = useNavigate();
const location = useLocation();
const { toast } = useToast();
Expand All @@ -48,11 +68,10 @@ export function AddGiftCard({ giftCard }: AddGiftCardProps) {
const handleAddCard = async () => {
setIsAdding(true);
try {
await addAccount({
await addGiftCard({
name: giftCard.name,
currency: giftCard.currency,
mintUrl: giftCard.url,
type: 'cashu',
url: giftCard.url,
});
toast({
title: 'Success',
Expand Down
3 changes: 1 addition & 2 deletions app/features/gift-cards/gift-card-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) {
const isTransitioning = useViewTransitionState('/gift-cards');

const { data: giftCardAccounts } = useAccounts({
type: 'cashu',
onlyIncludeClosedLoopAccounts: true,
purpose: 'gift-card',
});

const card = giftCardAccounts.find((c) => c.id === cardId);
Expand Down
3 changes: 1 addition & 2 deletions app/features/gift-cards/gift-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ import {
*/
export function GiftCards() {
const { data: accounts } = useAccounts({
type: 'cashu',
onlyIncludeClosedLoopAccounts: true,
purpose: 'gift-card',
});

const navigate = useNavigate();
Expand Down
4 changes: 2 additions & 2 deletions app/features/receive/receive-cashu-token-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,7 @@ export function useReceiveCashuTokenAccounts(
return {
selectableAccounts: possibleDestinationAccounts.map(toOption),
receiveAccount: receiveAccount ? toOption(receiveAccount) : null,
isCrossMintSwapDisabled: sourceAccount.isTestMint,
sourceAccount: sourceAccount,
sourceAccount,
setReceiveAccount,
addAndSetReceiveAccount,
};
Expand Down Expand Up @@ -305,6 +304,7 @@ function getSparkAccountPlaceholder(): ReceiveCashuTokenAccount & {
id: 'spark-account-placeholder-id',
name: 'Bitcoin',
type: 'spark',
purpose: 'transactional',
isOnline: true,
currency: 'BTC',
wallet: createSparkWalletStub(
Expand Down
Loading