From 888e577921d5586e6762e4b911a7f1a53c7d93e3 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 27 Jan 2026 08:21:28 -0800 Subject: [PATCH] add account purpose and some more changes --- app/features/accounts/account-hooks.ts | 93 ++++++---- app/features/accounts/account-icons.tsx | 12 +- app/features/accounts/account-repository.ts | 2 + app/features/accounts/account-selector.tsx | 4 +- app/features/accounts/account.ts | 27 +++ app/features/gift-cards/add-gift-card.tsx | 27 ++- app/features/gift-cards/gift-card-details.tsx | 3 +- app/features/gift-cards/gift-cards.tsx | 3 +- .../receive/receive-cashu-token-hooks.tsx | 4 +- .../receive/receive-cashu-token-service.ts | 31 ++-- app/features/receive/receive-cashu-token.tsx | 25 ++- .../settings/accounts/add-mint-form.tsx | 7 +- .../settings/accounts/all-accounts.tsx | 2 +- app/features/settings/settings.tsx | 4 +- .../transactions/transaction-details.tsx | 4 +- app/features/user/user-hooks.tsx | 3 + app/features/user/user-repository.ts | 1 + app/lib/cashu/utils.ts | 27 ++- supabase/database.types.ts | 4 + .../20260112180000_add_account_purpose.sql | 174 ++++++++++++++++++ 20 files changed, 366 insertions(+), 91 deletions(-) create mode 100644 supabase/migrations/20260112180000_add_account_purpose.sql diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index eecc8f477..7e098cba0 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -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, @@ -140,23 +141,54 @@ export const accountsQueryOptions = ({ }); }; -export function useAccounts(select?: { - currency?: Currency; - type?: T; - isOnline?: boolean; - excludeClosedLoopAccounts?: boolean; - onlyIncludeClosedLoopAccounts?: boolean; -}): UseSuspenseQueryResult[]> { +/** + * 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[]>; +export function useAccounts< + T extends AccountType = AccountType, + P extends AccountPurpose = AccountPurpose, +>( + select?: UseAccountsSelect, +): UseSuspenseQueryResult[]>; +export function useAccounts< + T extends AccountType = AccountType, + P extends AccountPurpose = AccountPurpose, +>( + select?: UseAccountsSelect, +): UseSuspenseQueryResult[]> { 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 }), @@ -166,13 +198,7 @@ export function useAccounts(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( @@ -193,13 +219,8 @@ export function useAccounts(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; }, @@ -212,14 +233,7 @@ export function useAccounts(select?: { new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); }, - [ - currency, - type, - isOnline, - excludeClosedLoopAccounts, - onlyIncludeClosedLoopAccounts, - user, - ], + [currency, type, isOnline, purpose, user], ), }); } @@ -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; diff --git a/app/features/accounts/account-icons.tsx b/app/features/accounts/account-icons.tsx index 1d3130f88..6cb399d95 100644 --- a/app/features/accounts/account-icons.tsx +++ b/app/features/accounts/account-icons.tsx @@ -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 = () => ; const SparkIcon = () => ; +const GiftCardIcon = () => ; const iconsByAccountType: Record = { cashu: , spark: , }; -export function AccountTypeIcon({ type }: { type: AccountType }) { - return iconsByAccountType[type]; +export function AccountIcon({ account }: { account: Account }) { + if (account.purpose === 'gift-card') { + return ; + } + return iconsByAccountType[account.type]; } diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index 8d91088bf..d170a7f0a 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -127,6 +127,7 @@ export class AccountRepository { currency: accountInput.currency, details, user_id: accountInput.userId, + purpose: accountInput.purpose, }; const query = this.db @@ -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, }; diff --git a/app/features/accounts/account-selector.tsx b/app/features/accounts/account-selector.tsx index 97660cebf..0a05a5218 100644 --- a/app/features/accounts/account-selector.tsx +++ b/app/features/accounts/account-selector.tsx @@ -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 & { @@ -46,7 +46,7 @@ function AccountItem({ account }: { account: AccountSelectorOption }) { return (
- +
{account.name}
diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index 5be02bdec..d1693a5fc 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -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; @@ -41,6 +48,7 @@ export type Account = { id: string; name: string; type: AccountType; + purpose: AccountPurpose; isOnline: boolean; currency: Currency; createdAt: string; @@ -87,6 +95,25 @@ export type SparkAccount = Extract; 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); diff --git a/app/features/gift-cards/add-gift-card.tsx b/app/features/gift-cards/add-gift-card.tsx index 067afa743..e72ed5f2e 100644 --- a/app/features/gift-cards/add-gift-card.tsx +++ b/app/features/gift-cards/add-gift-card.tsx @@ -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; }; @@ -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(); @@ -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', diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx index b43d5eeb4..111127feb 100644 --- a/app/features/gift-cards/gift-card-details.tsx +++ b/app/features/gift-cards/gift-card-details.tsx @@ -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); diff --git a/app/features/gift-cards/gift-cards.tsx b/app/features/gift-cards/gift-cards.tsx index ba027b1aa..b15eec8d3 100644 --- a/app/features/gift-cards/gift-cards.tsx +++ b/app/features/gift-cards/gift-cards.tsx @@ -27,8 +27,7 @@ import { */ export function GiftCards() { const { data: accounts } = useAccounts({ - type: 'cashu', - onlyIncludeClosedLoopAccounts: true, + purpose: 'gift-card', }); const navigate = useNavigate(); diff --git a/app/features/receive/receive-cashu-token-hooks.tsx b/app/features/receive/receive-cashu-token-hooks.tsx index 347d9b2b6..dfd6973e8 100644 --- a/app/features/receive/receive-cashu-token-hooks.tsx +++ b/app/features/receive/receive-cashu-token-hooks.tsx @@ -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, }; @@ -305,6 +304,7 @@ function getSparkAccountPlaceholder(): ReceiveCashuTokenAccount & { id: 'spark-account-placeholder-id', name: 'Bitcoin', type: 'spark', + purpose: 'transactional', isOnline: true, currency: 'BTC', wallet: createSparkWalletStub( diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index 488468694..97648e840 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -2,9 +2,11 @@ import type { Token } from '@cashu/cashu-ts'; import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import { areMintUrlsEqual, getCashuProtocolUnit } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; -import type { - ExtendedAccount, - ExtendedCashuAccount, +import { + type ExtendedAccount, + type ExtendedCashuAccount, + canReceiveFromLightning, + canSendToLightning, } from '../accounts/account'; import { cashuMintValidator, @@ -41,6 +43,7 @@ export class ReceiveCashuTokenService { const baseAccount = { id: 'cashu-account-placeholder-id', type: 'cashu' as const, + purpose: wallet.purpose, name: mintUrl.replace('https://', '').replace('http://', ''), mintUrl, createdAt: new Date().toISOString(), @@ -141,25 +144,21 @@ export class ReceiveCashuTokenService { /** * Returns the default receive account, or null if the token cannot be received. - * If the token is from a test mint, the source account will be returned if it is selectable, because tokens from test mint can only be claimed to the same mint. - * If the token is not from a test mint, the preferred receive account will be returned if it is selectable. + * If the token is from a test mint or gift card, the source account will be returned if it is selectable. + * If the token is not from a test mint or gift card, the preferred receive account will be returned if it is selectable. * If the preferred receive account is not selectable, the default account will be returned. * @param sourceAccount The source account of the token * @param possibleDestinationAccounts The possible destination accounts (cashu and spark) * @param preferredReceiveAccountId The preferred receive account id - * @returns + * @returns The default account to receive the token, or null if none available */ static getDefaultReceiveAccount( sourceAccount: CashuAccountWithTokenFlags, possibleDestinationAccounts: ReceiveCashuTokenAccount[], preferredReceiveAccountId?: string, ): ReceiveCashuTokenAccount | null { - if (sourceAccount.isTestMint) { - if (!sourceAccount.canReceive) { - return null; - } - // Tokens sourced from test mint can only be claimed to the same mint - return sourceAccount; + if (!canSendToLightning(sourceAccount)) { + return sourceAccount.canReceive ? sourceAccount : null; } const preferredReceiveAccount = possibleDestinationAccounts.find( @@ -193,13 +192,14 @@ export class ReceiveCashuTokenService { ...account, isSource: false, isUnknown: false, - canReceive: account.type === 'spark' || !account.isTestMint, + canReceive: canReceiveFromLightning(account), })); } /** * Returns the possible destination accounts that can receive the token from the source account. - * If the source account is from a test mint, the only account that can receive the token is the same source account. + * If the source account is from a test mint or is a gift card account, the only account that + * can receive the token is the same source account. * @param sourceAccount The source account of the token * @param otherAccounts The other user's accounts * @returns The possible destination accounts @@ -208,8 +208,7 @@ export class ReceiveCashuTokenService { sourceAccount: CashuAccountWithTokenFlags, otherAccounts: ReceiveCashuTokenAccount[], ): ReceiveCashuTokenAccount[] { - if (sourceAccount.isTestMint) { - // Tokens sourced from test mint can only be claimed to the same mint + if (!canSendToLightning(sourceAccount)) { return sourceAccount.canReceive ? [sourceAccount] : []; } return [sourceAccount, ...otherAccounts].filter( diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 529b6361d..e8007b74c 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -26,6 +26,8 @@ import { useNavigateWithViewTransition, } from '~/lib/transitions'; import { AccountSelector } from '../accounts/account-selector'; +import { GiftCardItem } from '../gift-cards/gift-card-item'; +import { getGiftCardImageByMintUrl } from '../gift-cards/use-discover-cards'; import { tokenToMoney } from '../shared/cashu'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; @@ -108,12 +110,13 @@ export default function ReceiveToken({ const { selectableAccounts, receiveAccount, - isCrossMintSwapDisabled, sourceAccount, setReceiveAccount, addAndSetReceiveAccount, } = useReceiveCashuTokenAccounts(token, preferredReceiveAccountId); + const isGiftCardSource = sourceAccount.purpose === 'gift-card'; + const isReceiveAccountKnown = receiveAccount?.isUnknown === false; const { mutateAsync: createCashuTokenSwap } = useCreateCashuTokenSwap(); @@ -192,12 +195,20 @@ export default function ReceiveToken({
{claimableToken && receiveAccount ? (
- + {isGiftCardSource ? ( + + ) : ( + + )}
) : ( { try { + const mintInfo = await queryClient.fetchQuery( + mintInfoQueryOptions(data.mintUrl), + ); + const purpose = getMintPurpose(mintInfo); await addAccount({ name: data.name, currency: data.currency, mintUrl: data.mintUrl, type: 'cashu', + purpose, }); toast({ title: 'Success', diff --git a/app/features/settings/accounts/all-accounts.tsx b/app/features/settings/accounts/all-accounts.tsx index ebe015a4d..c23127efb 100644 --- a/app/features/settings/accounts/all-accounts.tsx +++ b/app/features/settings/accounts/all-accounts.tsx @@ -17,7 +17,7 @@ import { LinkWithViewTransition } from '~/lib/transitions'; function CurrencyAccounts({ currency }: { currency: Currency }) { const { data: accounts } = useAccounts({ currency, - excludeClosedLoopAccounts: true, + purpose: 'transactional', }); return ( diff --git a/app/features/settings/settings.tsx b/app/features/settings/settings.tsx index 1efcc4246..fa532ac71 100644 --- a/app/features/settings/settings.tsx +++ b/app/features/settings/settings.tsx @@ -20,7 +20,7 @@ import { canShare, shareContent } from '~/lib/share'; import { LinkWithViewTransition } from '~/lib/transitions'; import { cn } from '~/lib/utils'; import { useDefaultAccount } from '../accounts/account-hooks'; -import { AccountTypeIcon } from '../accounts/account-icons'; +import { AccountIcon } from '../accounts/account-icons'; import { ColorModeToggle } from '../theme/color-mode-toggle'; import { useSignOut } from '../user/auth'; import { useUser } from '../user/user-hooks'; @@ -114,7 +114,7 @@ export default function Settings() { - + {defaultAccount.name} diff --git a/app/features/transactions/transaction-details.tsx b/app/features/transactions/transaction-details.tsx index eca73177d..0bc3271dd 100644 --- a/app/features/transactions/transaction-details.tsx +++ b/app/features/transactions/transaction-details.tsx @@ -25,7 +25,7 @@ import type { import { useToast } from '~/hooks/use-toast'; import { LinkWithViewTransition } from '~/lib/transitions'; import { useAccount } from '../accounts/account-hooks'; -import { AccountTypeIcon } from '../accounts/account-icons'; +import { AccountIcon } from '../accounts/account-icons'; import { getDefaultUnit } from '../shared/currencies'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; @@ -337,7 +337,7 @@ export function TransactionDetails({
- + {account?.name}
diff --git a/app/features/user/user-hooks.tsx b/app/features/user/user-hooks.tsx index c90403b56..67db4ce19 100644 --- a/app/features/user/user-hooks.tsx +++ b/app/features/user/user-hooks.tsx @@ -82,6 +82,7 @@ export const defaultAccounts = [ name: 'Bitcoin', network: 'MAINNET', isDefault: true, + purpose: 'transactional', }, ...(isDevelopmentMode ? ([ @@ -92,6 +93,7 @@ export const defaultAccounts = [ mintUrl: 'https://testnut.cashu.space', isTestMint: true, isDefault: false, + purpose: 'transactional', }, { type: 'cashu', @@ -100,6 +102,7 @@ export const defaultAccounts = [ mintUrl: 'https://testnut.cashu.space', isTestMint: true, isDefault: true, + purpose: 'transactional', }, ] as const) : []), diff --git a/app/features/user/user-repository.ts b/app/features/user/user-repository.ts index afd619562..667d36988 100644 --- a/app/features/user/user-repository.ts +++ b/app/features/user/user-repository.ts @@ -154,6 +154,7 @@ export class UserRepository { type: account.type, currency: account.currency, is_default: account.isDefault ?? false, + purpose: account.purpose, details: (() => { if (account.type === 'cashu') { return { diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index 10f95799c..4b267f6c1 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -78,6 +78,22 @@ export const getCashuProtocolUnit = (currency: Currency) => { return currencyToCashuProtocolUnit[currency]; }; +/** + * Determines the purpose of a mint based on its info. + */ +export const getMintPurpose = ( + mintInfo: ExtendedMintInfo | null | undefined, +): 'gift-card' | 'transactional' => { + // TODO: This should check this.mintInfo?.agicash?.closed_loop once Agicash mints change to that + // TODO: Should the mint explicitly signal the purpose? + const bolt11Method = mintInfo?.nuts?.[5]?.methods?.find( + (m) => m.method === 'bolt11', + ) as SwapMethod & { options?: { internal_melts_only?: boolean } }; + return bolt11Method?.options?.internal_melts_only + ? 'gift-card' + : 'transactional'; +}; + export const getWalletCurrency = (wallet: CashuWallet) => { const unit = wallet.unit as keyof typeof cashuProtocolUnitToCurrency; if (!cashuProtocolUnitToCurrency[unit]) { @@ -124,15 +140,10 @@ export class ExtendedCashuWallet extends CashuWallet { } /** - * Indicates whether the mint operates in closed-loop mode. - * When true, the mint will only process payments to destinations within its loop. + * Gets the purpose of this mint based on its configuration. */ - get isClosedLoop(): boolean { - // TODO: This should check this.mintInfo?.agicash?.closed_loop once Agicash mints change to that - const bolt11Method = this.mintInfo?.nuts?.[5]?.methods?.find( - (m) => m.method === 'bolt11', - ) as SwapMethod & { options: { internal_melts_only: boolean } }; - return bolt11Method?.options?.internal_melts_only ?? false; + get purpose(): 'gift-card' | 'transactional' { + return getMintPurpose(this.mintInfo); } /** diff --git a/supabase/database.types.ts b/supabase/database.types.ts index a06d0c792..49e12e545 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -16,6 +16,7 @@ export type Database = { details: Json id: string name: string + purpose: string type: string user_id: string version: number @@ -26,6 +27,7 @@ export type Database = { details: Json id?: string name: string + purpose?: string type: string user_id: string version?: number @@ -36,6 +38,7 @@ export type Database = { details?: Json id?: string name?: string + purpose?: string type?: string user_id?: string version?: number @@ -1565,6 +1568,7 @@ export type Database = { name: string | null details: Json | null is_default: boolean | null + purpose: string | null } add_cashu_proofs_and_update_account_result: { account: Json | null diff --git a/supabase/migrations/20260112180000_add_account_purpose.sql b/supabase/migrations/20260112180000_add_account_purpose.sql new file mode 100644 index 000000000..1554e6b22 --- /dev/null +++ b/supabase/migrations/20260112180000_add_account_purpose.sql @@ -0,0 +1,174 @@ +-- Migration: Add Account Purpose Column +-- +-- Purpose: +-- 1. Add 'purpose' column to accounts table to distinguish account types +-- 2. Update account_input composite type to include purpose +-- 3. Update upsert_user_with_accounts function to handle purpose +-- +-- Affected Objects: +-- - wallet.accounts (table - new column) +-- - wallet.account_input (composite type - new field) +-- - wallet.upsert_user_with_accounts (function) +-- +-- Changes: +-- 1. Added purpose column with values: 'transactional' (default) or 'gift-card' +-- 2. Modified account_input type to include purpose field +-- 3. Modified upsert function to insert purpose when creating accounts +-- +-- Special Considerations: +-- - Existing accounts default to 'transactional' purpose +-- - The purpose distinguishes regular accounts from closed-loop gift card accounts + +-- Add purpose column to accounts table with default value for existing rows +alter table wallet.accounts + add column purpose text not null default 'transactional'; + +-- Add check constraint to validate purpose values +alter table wallet.accounts + add constraint accounts_purpose_check + check (purpose in ('transactional', 'gift-card')); + +-- Recreate account_input type with purpose field +-- Must drop and recreate since ALTER TYPE doesn't support adding fields to composite types +drop type if exists wallet.account_input cascade; +create type wallet.account_input as ( + "type" text, + "currency" text, + "name" text, + "details" jsonb, + "is_default" boolean, + "purpose" text +); + +-- Drop old function signature before recreating +drop function if exists wallet.upsert_user_with_accounts(uuid, text, boolean, wallet.account_input[], text, text, text); + +-- Recreate upsert_user_with_accounts function with purpose support +create or replace function wallet.upsert_user_with_accounts( + p_user_id uuid, + p_email text, + p_email_verified boolean, + p_accounts wallet.account_input[], + p_cashu_locking_xpub text, + p_encryption_public_key text, + p_spark_identity_public_key text +) +returns wallet.upsert_user_with_accounts_result +language plpgsql +as $function$ +declare + result_user wallet.users; + result_accounts jsonb[]; + usd_account_id uuid := null; + btc_account_id uuid := null; + placeholder_btc_account_id uuid := gen_random_uuid(); +begin + -- Insert user with placeholder default_btc_account_id. The FK constraint is deferred, + -- so it won't be checked until transaction commit. We'll update it with the real + -- account ID after creating accounts. + insert into wallet.users (id, email, email_verified, cashu_locking_xpub, encryption_public_key, spark_identity_public_key, default_currency, default_btc_account_id) + values (p_user_id, p_email, p_email_verified, p_cashu_locking_xpub, p_encryption_public_key, p_spark_identity_public_key, 'BTC', placeholder_btc_account_id) + on conflict (id) do update set + email = coalesce(excluded.email, wallet.users.email), + email_verified = excluded.email_verified; + + select * + into result_user + from wallet.users u + where u.id = p_user_id + for update; + + with accounts_with_proofs as ( + select + a.*, + coalesce( + jsonb_agg(to_jsonb(cp)) filter (where cp.id is not null), + '[]'::jsonb + ) as cashu_proofs + from + wallet.accounts a + left join wallet.cashu_proofs cp on cp.account_id = a.id and cp.state = 'UNSPENT' + where a.user_id = p_user_id + group by a.id + ) + select array_agg( + jsonb_set( + to_jsonb(awp), + '{cashu_proofs}', + awp.cashu_proofs + ) + ) + into result_accounts + from accounts_with_proofs awp; + + if result_accounts is not null then + return (result_user, result_accounts); + end if; + + if array_length(p_accounts, 1) is null then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'p_accounts cannot be an empty array'; + end if; + + if not exists (select 1 from unnest(p_accounts) as acct where acct.currency = 'BTC' and acct.type = 'spark') then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'At least one BTC Spark account is required'; + end if; + + with + inserted_accounts as ( + insert into wallet.accounts (user_id, type, currency, name, details, purpose) + select + p_user_id, + acct.type, + acct.currency, + acct.name, + acct.details, + acct.purpose + from unnest(p_accounts) as acct + returning * + ), + accounts_with_default_flag as ( + select + ia.*, + coalesce(acct."is_default", false) as "is_default" + from + inserted_accounts ia + join unnest(p_accounts) as acct on + ia.type = acct.type and + ia.currency = acct.currency and + ia.name = acct.name and + ia.details = acct.details + ) + select + array_agg( + jsonb_set( + to_jsonb(awd), + '{cashu_proofs}', + '[]'::jsonb + ) + ), + (array_agg(awd.id) filter (where awd.currency = 'USD' and awd."is_default"))[1], + (array_agg(awd.id) filter (where awd.currency = 'BTC' and awd."is_default"))[1] + into result_accounts, usd_account_id, btc_account_id + from accounts_with_default_flag awd; + + update wallet.users u + set + default_usd_account_id = coalesce(usd_account_id, u.default_usd_account_id), + default_btc_account_id = coalesce(btc_account_id, u.default_btc_account_id), + default_currency = case + when btc_account_id is not null then 'BTC' + when usd_account_id is not null then 'USD' + else u.default_currency + end + where id = p_user_id + returning * into result_user; + + return (result_user, result_accounts); +end; +$function$;