From 6423c972a8650d77add32549167f36382f526ee4 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 5 Mar 2026 13:29:32 -0800 Subject: [PATCH] Add BUY and TRANSFER to receive quote and transaction type enums --- app/features/buy/buy-store.ts | 4 + .../receive/cashu-receive-quote-core.ts | 32 ++- .../receive/cashu-receive-quote-hooks.ts | 10 +- app/features/receive/cashu-receive-quote.ts | 6 +- .../receive/spark-receive-quote-core.ts | 40 +-- .../receive/spark-receive-quote-hooks.ts | 7 +- .../receive/spark-receive-quote-service.ts | 2 +- app/features/receive/spark-receive-quote.ts | 6 +- .../transactions/transaction-enums.ts | 2 + supabase/database.types.ts | 19 +- ...000_add_transfer_and_buy_receive_types.sql | 251 ++++++++++++++++++ 11 files changed, 335 insertions(+), 44 deletions(-) create mode 100644 supabase/migrations/20260304120000_add_transfer_and_buy_receive_types.sql diff --git a/app/features/buy/buy-store.ts b/app/features/buy/buy-store.ts index 4a9ed120..7ba3ca48 100644 --- a/app/features/buy/buy-store.ts +++ b/app/features/buy/buy-store.ts @@ -38,11 +38,13 @@ type CreateBuyStoreProps = { account: CashuAccount; amount: Money; description?: string; + receiveType: 'BUY'; }) => Promise; createSparkReceiveQuote: (params: { account: SparkAccount; amount: Money; description?: string; + receiveType: 'BUY'; }) => Promise; }; @@ -72,6 +74,7 @@ export const createBuyStore = ({ account, amount, description: 'Pay to Agicash', + receiveType: 'BUY', }); quote = { id: cashuQuote.id, @@ -84,6 +87,7 @@ export const createBuyStore = ({ account, amount, description: 'Pay to Agicash', + receiveType: 'BUY', }); quote = { id: sparkQuote.id, diff --git a/app/features/receive/cashu-receive-quote-core.ts b/app/features/receive/cashu-receive-quote-core.ts index fae09a46..9fbf3deb 100644 --- a/app/features/receive/cashu-receive-quote-core.ts +++ b/app/features/receive/cashu-receive-quote-core.ts @@ -78,13 +78,15 @@ export type CreateQuoteBaseParams = { /** * Type of the receive. * - LIGHTNING - The money is received via a regular lightning payment. + * - BUY - A purchase of bitcoin. The receive quote is created by the buyer, who pays the Lightning invoice through an external payment method. + * - TRANSFER - An internal transfer between accounts. The receive quote is created and paid automatically by the app. * - CASHU_TOKEN - The money is received as a cashu token. The proofs will be melted * from the account they originated from to pay the request for this receive quote. */ - receiveType: 'LIGHTNING' | 'CASHU_TOKEN'; + receiveType: 'LIGHTNING' | 'BUY' | 'TRANSFER' | 'CASHU_TOKEN'; } & ( | { - receiveType: 'LIGHTNING'; + receiveType: 'LIGHTNING' | 'BUY' | 'TRANSFER'; } | { receiveType: 'CASHU_TOKEN'; @@ -176,7 +178,7 @@ export type RepositoryCreateQuoteParams = { totalFee: Money; } & ( | { - receiveType: 'LIGHTNING'; + receiveType: 'LIGHTNING' | 'BUY' | 'TRANSFER'; } | { receiveType: 'CASHU_TOKEN'; @@ -282,16 +284,16 @@ export async function getLightningQuote( * For CASHU_TOKEN type quotes, the expiry is the earler of the mint quote expiry and the melt quote expiry. */ export function computeQuoteExpiry(params: CreateQuoteBaseParams): string { - if (params.receiveType === 'LIGHTNING') { - return params.lightningQuote.expiresAt; + if (params.receiveType === 'CASHU_TOKEN') { + return new Date( + Math.min( + new Date(params.lightningQuote.expiresAt).getTime(), + new Date(params.meltQuoteExpiresAt).getTime(), + ), + ).toISOString(); } - return new Date( - Math.min( - new Date(params.lightningQuote.expiresAt).getTime(), - new Date(params.meltQuoteExpiresAt).getTime(), - ), - ).toISOString(); + return params.lightningQuote.expiresAt; } /** @@ -304,9 +306,11 @@ export function computeTotalFee(params: CreateQuoteBaseParams): Money { params.lightningQuote.mintingFee ?? Money.zero(params.lightningQuote.amount.currency); - if (params.receiveType === 'LIGHTNING') { - return mintingFee; + if (params.receiveType === 'CASHU_TOKEN') { + return mintingFee + .add(params.cashuReceiveFee) + .add(params.lightningFeeReserve); } - return mintingFee.add(params.cashuReceiveFee).add(params.lightningFeeReserve); + return mintingFee; } diff --git a/app/features/receive/cashu-receive-quote-hooks.ts b/app/features/receive/cashu-receive-quote-hooks.ts index 642dffed..e406d40d 100644 --- a/app/features/receive/cashu-receive-quote-hooks.ts +++ b/app/features/receive/cashu-receive-quote-hooks.ts @@ -42,6 +42,7 @@ type CreateProps = { account: CashuAccount; amount: Money; description?: string; + receiveType?: 'LIGHTNING' | 'BUY'; }; class CashuReceiveQuoteCache { // Query that tracks the "active" cashu receive quote. Active one is the one that user created in current browser session. @@ -154,7 +155,12 @@ export function useCreateCashuReceiveQuote() { scope: { id: 'create-cashu-receive-quote', }, - mutationFn: async ({ account, amount, description }: CreateProps) => { + mutationFn: async ({ + account, + amount, + description, + receiveType = 'LIGHTNING', + }: CreateProps) => { const lightningQuote = await cashuReceiveQuoteService.getLightningQuote({ wallet: account.wallet, amount, @@ -164,7 +170,7 @@ export function useCreateCashuReceiveQuote() { return cashuReceiveQuoteService.createReceiveQuote({ userId, account, - receiveType: 'LIGHTNING', + receiveType, lightningQuote, }); }, diff --git a/app/features/receive/cashu-receive-quote.ts b/app/features/receive/cashu-receive-quote.ts index cfbf2e24..5153f8ce 100644 --- a/app/features/receive/cashu-receive-quote.ts +++ b/app/features/receive/cashu-receive-quote.ts @@ -83,9 +83,11 @@ const CashuReceiveQuoteBaseSchema = z.object({ const CashuReceiveQuoteLightningTypeSchema = z.object({ /** * Type of the receive. - * LIGHTNING - The money is received via Lightning. + * LIGHTNING - The money is received via a regular lightning payment. + * BUY - A purchase of bitcoin. The receive quote is created by the buyer, who pays the Lightning invoice through an external payment method. + * TRANSFER - An internal transfer between accounts. The receive quote is created and paid automatically by the app. */ - type: z.literal('LIGHTNING'), + type: z.enum(['LIGHTNING', 'BUY', 'TRANSFER']), }); const CashuReceiveQuoteCashuTokenTypeSchema = z.object({ diff --git a/app/features/receive/spark-receive-quote-core.ts b/app/features/receive/spark-receive-quote-core.ts index c21a4555..d83843e0 100644 --- a/app/features/receive/spark-receive-quote-core.ts +++ b/app/features/receive/spark-receive-quote-core.ts @@ -79,9 +79,11 @@ export type CreateQuoteBaseParams = { | { /** * Type of the receive. - * LIGHTNING - Standard lightning receive. + * LIGHTNING - The money is received via a regular lightning payment. + * BUY - A purchase of bitcoin. The receive quote is created by the buyer, who pays the Lightning invoice through an external payment method. + * TRANSFER - An internal transfer between accounts. The receive quote is created and paid automatically by the app. */ - receiveType: 'LIGHTNING'; + receiveType: 'LIGHTNING' | 'BUY' | 'TRANSFER'; } | { /** @@ -165,9 +167,11 @@ export type RepositoryCreateQuoteParams = { | { /** * Type of the receive. - * LIGHTNING - Standard lightning receive. + * LIGHTNING - The money is received via a regular lightning payment. + * BUY - A purchase of bitcoin. The receive quote is created by the buyer, who pays the Lightning invoice through an external payment method. + * TRANSFER - An internal transfer between accounts. The receive quote is created and paid automatically by the app. */ - receiveType: 'LIGHTNING'; + receiveType: 'LIGHTNING' | 'BUY' | 'TRANSFER'; } | { /** @@ -256,16 +260,16 @@ export async function getLightningQuote({ * For CASHU_TOKEN type, returns the earlier of lightning and melt quote expiry. */ export function computeQuoteExpiry(params: CreateQuoteBaseParams): string { - if (params.receiveType === 'LIGHTNING') { - return params.lightningQuote.invoice.expiresAt; + if (params.receiveType === 'CASHU_TOKEN') { + return new Date( + Math.min( + new Date(params.lightningQuote.invoice.expiresAt).getTime(), + new Date(params.meltQuoteExpiresAt).getTime(), + ), + ).toISOString(); } - return new Date( - Math.min( - new Date(params.lightningQuote.invoice.expiresAt).getTime(), - new Date(params.meltQuoteExpiresAt).getTime(), - ), - ).toISOString(); + return params.lightningQuote.invoice.expiresAt; } /** @@ -279,12 +283,12 @@ export function getAmountAndFee(params: CreateQuoteBaseParams): { } { const amount = moneyFromSparkAmount(params.lightningQuote.invoice.amount); - if (params.receiveType === 'LIGHTNING') { - return { amount, totalFee: Money.zero(amount.currency) }; + if (params.receiveType === 'CASHU_TOKEN') { + return { + amount, + totalFee: params.cashuReceiveFee.add(params.lightningFeeReserve), + }; } - return { - amount, - totalFee: params.cashuReceiveFee.add(params.lightningFeeReserve), - }; + return { amount, totalFee: Money.zero(amount.currency) }; } diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index 3fc9243d..6f920130 100644 --- a/app/features/receive/spark-receive-quote-hooks.ts +++ b/app/features/receive/spark-receive-quote-hooks.ts @@ -272,6 +272,10 @@ type CreateProps = { * Description to include in the Lightning invoice memo. */ description?: string; + /** + * Type of the receive. Defaults to 'LIGHTNING'. + */ + receiveType?: 'LIGHTNING' | 'BUY'; }; /** @@ -292,6 +296,7 @@ export function useCreateSparkReceiveQuote() { amount, receiverIdentityPubkey, description, + receiveType = 'LIGHTNING', }: CreateProps) => { const lightningQuote = await getLightningQuote({ wallet: account.wallet, @@ -304,7 +309,7 @@ export function useCreateSparkReceiveQuote() { userId, account, lightningQuote, - receiveType: 'LIGHTNING', + receiveType, }); }, onSuccess: (data) => { diff --git a/app/features/receive/spark-receive-quote-service.ts b/app/features/receive/spark-receive-quote-service.ts index 44230f32..eb0d102b 100644 --- a/app/features/receive/spark-receive-quote-service.ts +++ b/app/features/receive/spark-receive-quote-service.ts @@ -55,7 +55,7 @@ export class SparkReceiveQuoteService { return this.repository.create({ ...baseParams, - receiveType: 'LIGHTNING', + receiveType: params.receiveType, }); } diff --git a/app/features/receive/spark-receive-quote.ts b/app/features/receive/spark-receive-quote.ts index 8ce84209..ac882de3 100644 --- a/app/features/receive/spark-receive-quote.ts +++ b/app/features/receive/spark-receive-quote.ts @@ -74,9 +74,11 @@ const SparkReceiveQuoteBaseSchema = z.object({ const SparkReceiveQuoteLightningTypeSchema = z.object({ /** * Type of the receive. - * LIGHTNING - The money is received via regular Lightning flow. User provides the lightning invoice to the payer who then pays the invoice. + * LIGHTNING - The money is received via a regular lightning payment. + * BUY - A purchase of bitcoin. The receive quote is created by the buyer, who pays the Lightning invoice through an external payment method. + * TRANSFER - An internal transfer between accounts. The receive quote is created and paid automatically by the app. */ - type: z.literal('LIGHTNING'), + type: z.enum(['LIGHTNING', 'BUY', 'TRANSFER']), }); const SparkReceiveQuoteCashuTokenTypeSchema = z.object({ diff --git a/app/features/transactions/transaction-enums.ts b/app/features/transactions/transaction-enums.ts index 9869c430..6e69629c 100644 --- a/app/features/transactions/transaction-enums.ts +++ b/app/features/transactions/transaction-enums.ts @@ -8,6 +8,8 @@ export const TransactionTypeSchema = z.enum([ 'CASHU_LIGHTNING', 'CASHU_TOKEN', 'SPARK_LIGHTNING', + 'BUY', + 'TRANSFER', ]); export type TransactionType = z.infer; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 3368c363..94bd44a2 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1612,7 +1612,7 @@ export type Database = { | "FAILED" | "REVERSED" currency: "BTC" | "USD" - receive_quote_type: "LIGHTNING" | "CASHU_TOKEN" + receive_quote_type: "LIGHTNING" | "CASHU_TOKEN" | "TRANSFER" | "BUY" spark_receive_quote_state: "UNPAID" | "EXPIRED" | "PAID" | "FAILED" spark_send_quote_state: "UNPAID" | "PENDING" | "COMPLETED" | "FAILED" transaction_direction: "SEND" | "RECEIVE" @@ -1622,7 +1622,12 @@ export type Database = { | "COMPLETED" | "FAILED" | "REVERSED" - transaction_type: "CASHU_LIGHTNING" | "CASHU_TOKEN" | "SPARK_LIGHTNING" + transaction_type: + | "CASHU_LIGHTNING" + | "CASHU_TOKEN" + | "SPARK_LIGHTNING" + | "TRANSFER" + | "BUY" } CompositeTypes: { account_input: { @@ -1900,7 +1905,7 @@ export const Constants = { "REVERSED", ], currency: ["BTC", "USD"], - receive_quote_type: ["LIGHTNING", "CASHU_TOKEN"], + receive_quote_type: ["LIGHTNING", "CASHU_TOKEN", "TRANSFER", "BUY"], spark_receive_quote_state: ["UNPAID", "EXPIRED", "PAID", "FAILED"], spark_send_quote_state: ["UNPAID", "PENDING", "COMPLETED", "FAILED"], transaction_direction: ["SEND", "RECEIVE"], @@ -1911,7 +1916,13 @@ export const Constants = { "FAILED", "REVERSED", ], - transaction_type: ["CASHU_LIGHTNING", "CASHU_TOKEN", "SPARK_LIGHTNING"], + transaction_type: [ + "CASHU_LIGHTNING", + "CASHU_TOKEN", + "SPARK_LIGHTNING", + "TRANSFER", + "BUY", + ], }, }, } as const diff --git a/supabase/migrations/20260304120000_add_transfer_and_buy_receive_types.sql b/supabase/migrations/20260304120000_add_transfer_and_buy_receive_types.sql new file mode 100644 index 00000000..9201d950 --- /dev/null +++ b/supabase/migrations/20260304120000_add_transfer_and_buy_receive_types.sql @@ -0,0 +1,251 @@ +-- ============================================================================= +-- Add TRANSFER and BUY to receive_quote_type and transaction_type enums +-- ============================================================================= +-- +-- This migration extends both the receive_quote_type and transaction_type enums +-- with two new values and updates both quote-creation functions to handle them. +-- +-- New receive_quote_type values: +-- +-- BUY — A purchase of bitcoin where the buyer pays a Lightning invoice +-- via an external payment method (e.g. Cash App Pay). Treated the +-- same as LIGHTNING: the invoice may never be paid, so the +-- transaction starts as DRAFT. +-- +-- TRANSFER — An internal transfer between accounts initiated by the app +-- itself. Because the payment is app-controlled it is guaranteed +-- to be sent, so the transaction starts as PENDING (like +-- CASHU_TOKEN). +-- +-- New transaction_type values: +-- +-- BUY — Transaction created from a BUY receive quote. Carries the +-- semantic meaning that the user purchased bitcoin rather than +-- receiving a regular Lightning payment. +-- +-- TRANSFER — Transaction created from a TRANSFER receive quote. Carries the +-- semantic meaning that funds were moved between accounts rather +-- than received externally. +-- +-- Functions updated: +-- wallet.create_cashu_receive_quote — BUY → BUY, TRANSFER → TRANSFER +-- wallet.create_spark_receive_quote — BUY → BUY, TRANSFER → TRANSFER +-- ============================================================================= + +alter type "wallet"."receive_quote_type" add value 'TRANSFER'; +alter type "wallet"."receive_quote_type" add value 'BUY'; + +alter type "wallet"."transaction_type" add value 'TRANSFER'; +alter type "wallet"."transaction_type" add value 'BUY'; + +-- Update create_cashu_receive_quote to handle TRANSFER and BUY receive types. +-- TRANSFER → TRANSFER (PENDING) — payment is app-initiated. +-- BUY → BUY (DRAFT) — invoice may never be paid. +create or replace function "wallet"."create_cashu_receive_quote"( + "p_user_id" "uuid", + "p_account_id" "uuid", + "p_currency" "wallet"."currency", + "p_expires_at" timestamp with time zone, + "p_locking_derivation_path" "text", + "p_receive_type" "wallet"."receive_quote_type", + "p_encrypted_data" "text", + "p_quote_id_hash" "text", + "p_payment_hash" "text" +) +returns "wallet"."cashu_receive_quotes" +language plpgsql +security invoker +set search_path = '' +as $function$ +declare + v_transaction_type wallet.transaction_type; + v_transaction_state wallet.transaction_state; + v_cashu_token_melt_initiated boolean; + v_transaction_id uuid; + v_quote wallet.cashu_receive_quotes; +begin + v_transaction_type := case p_receive_type + when 'LIGHTNING' then 'CASHU_LIGHTNING' + when 'TRANSFER' then 'TRANSFER' + when 'BUY' then 'BUY' + when 'CASHU_TOKEN' then 'CASHU_TOKEN' + else null + end; + + if v_transaction_type is null then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'Unsupported receive type', + detail = format('Expected one of: LIGHTNING, TRANSFER, BUY, CASHU_TOKEN. Value provided: %s', p_receive_type); + end if; + + -- CASHU_TOKEN and TRANSFER transactions start as PENDING because the payment + -- is initiated by the app (not waiting on an external payer). + -- LIGHTNING and BUY start as DRAFT because the invoice may never be paid. + v_transaction_state := case p_receive_type + when 'CASHU_TOKEN' then 'PENDING' + when 'TRANSFER' then 'PENDING' + else 'DRAFT' + end; + + v_cashu_token_melt_initiated := case p_receive_type + when 'CASHU_TOKEN' then false + else null + end; + + insert into wallet.transactions ( + user_id, + account_id, + direction, + type, + state, + currency, + encrypted_transaction_details, + transaction_details + ) values ( + p_user_id, + p_account_id, + 'RECEIVE', + v_transaction_type, + v_transaction_state, + p_currency, + p_encrypted_data, + jsonb_build_object('paymentHash', p_payment_hash) + ) returning id into v_transaction_id; + + insert into wallet.cashu_receive_quotes ( + user_id, + account_id, + expires_at, + state, + locking_derivation_path, + transaction_id, + type, + encrypted_data, + quote_id_hash, + payment_hash, + cashu_token_melt_initiated + ) values ( + p_user_id, + p_account_id, + p_expires_at, + 'UNPAID', + p_locking_derivation_path, + v_transaction_id, + p_receive_type, + p_encrypted_data, + p_quote_id_hash, + p_payment_hash, + v_cashu_token_melt_initiated + ) returning * into v_quote; + + return v_quote; +end; +$function$; + +-- Update create_spark_receive_quote to handle TRANSFER and BUY receive types. +-- TRANSFER → TRANSFER (PENDING) — payment is app-initiated. +-- BUY → BUY (DRAFT) — invoice may never be paid. +create or replace function "wallet"."create_spark_receive_quote"( + "p_user_id" "uuid", + "p_account_id" "uuid", + "p_currency" "wallet"."currency", + "p_payment_hash" "text", + "p_expires_at" timestamp with time zone, + "p_spark_id" "text", + "p_receiver_identity_pubkey" "text", + "p_receive_type" "wallet"."receive_quote_type", + "p_encrypted_data" "text" +) +returns "wallet"."spark_receive_quotes" +language plpgsql +security invoker +set search_path = '' +as $function$ +declare + v_transaction_type wallet.transaction_type; + v_transaction_state wallet.transaction_state; + v_cashu_token_melt_initiated boolean; + v_transaction_id uuid; + v_quote wallet.spark_receive_quotes; +begin + v_transaction_type := case p_receive_type + when 'LIGHTNING' then 'SPARK_LIGHTNING' + when 'TRANSFER' then 'TRANSFER' + when 'BUY' then 'BUY' + when 'CASHU_TOKEN' then 'CASHU_TOKEN' + else null + end; + + if v_transaction_type is null then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'Unsupported receive type', + detail = format('Expected one of: LIGHTNING, TRANSFER, BUY, CASHU_TOKEN. Value provided: %s', p_receive_type); + end if; + + -- CASHU_TOKEN and TRANSFER transactions start as PENDING because the payment + -- is initiated by the app (not waiting on an external payer). + -- LIGHTNING and BUY start as DRAFT because the invoice may never be paid. + v_transaction_state := case p_receive_type + when 'CASHU_TOKEN' then 'PENDING' + when 'TRANSFER' then 'PENDING' + else 'DRAFT' + end; + + v_cashu_token_melt_initiated := case p_receive_type + when 'CASHU_TOKEN' then false + else null + end; + + insert into wallet.transactions ( + user_id, + account_id, + direction, + type, + state, + currency, + encrypted_transaction_details, + transaction_details + ) values ( + p_user_id, + p_account_id, + 'RECEIVE', + v_transaction_type, + v_transaction_state, + p_currency, + p_encrypted_data, + jsonb_build_object('sparkId', p_spark_id, 'paymentHash', p_payment_hash) + ) returning id into v_transaction_id; + + insert into wallet.spark_receive_quotes ( + user_id, + account_id, + type, + payment_hash, + expires_at, + spark_id, + receiver_identity_pubkey, + transaction_id, + state, + encrypted_data, + cashu_token_melt_initiated + ) values ( + p_user_id, + p_account_id, + p_receive_type, + p_payment_hash, + p_expires_at, + p_spark_id, + p_receiver_identity_pubkey, + v_transaction_id, + 'UNPAID', + p_encrypted_data, + v_cashu_token_melt_initiated + ) returning * into v_quote; + + return v_quote; +end; +$function$;