diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 289fe06..a9e1ae3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,6 +5,7 @@ import useSatPrice from './useSatPrice'; import usePrint from './usePrint'; import useNfc from './useNfc'; import {useFlashcard} from './useFlashcard'; +import useRefund from './useRefund'; export { useActivityIndicator, @@ -14,4 +15,5 @@ export { usePrint, useNfc, useFlashcard, + useRefund, }; diff --git a/src/hooks/useRefund.tsx b/src/hooks/useRefund.tsx new file mode 100644 index 0000000..03176ec --- /dev/null +++ b/src/hooks/useRefund.tsx @@ -0,0 +1,109 @@ +import {useCallback, useRef} from 'react'; +import {Alert} from 'react-native'; +import {useAppDispatch, useAppSelector} from '../store/hooks'; +import {setRefunding, markRefunded} from '../store/slices/transactionHistorySlice'; + +interface RefundHookResult { + initiateRefund: (transaction: TransactionData) => Promise; + isRefunding: (id: string) => boolean; +} + +const useRefund = (): RefundHookResult => { + const dispatch = useAppDispatch(); + const refundingIds = useAppSelector( + state => state.transactionHistory.refundingIds, + ); + const processingIds = useRef>(new Set()); + + const isRefundable = useCallback((transaction: TransactionData): boolean => { + if (transaction.refunded) return false; + if (transaction.status !== 'completed') return false; + if (!transaction.amount.satAmount || transaction.amount.satAmount <= 0) + return false; + return true; + }, []); + + const initiateRefund = useCallback( + async (transaction: TransactionData) => { + if (!isRefundable(transaction)) return; + if (processingIds.current.has(transaction.id)) return; + + // Show confirmation dialog + Alert.alert( + 'Confirm Refund', + `Refund ${transaction.amount.satAmount} points to ${transaction.merchant.username}?`, + [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Refund', + style: 'destructive', + onPress: async () => { + processingIds.current.add(transaction.id); + dispatch( + setRefunding({id: transaction.id, isRefunding: true}), + ); + + try { + // Attempt LNURL-withdraw via QR code + // The lnurl-withdraw URL would be fetched from the merchant's + // Lightning node (e.g., BTC Pay Server pull-payments API) + // For now, this triggers the withdraw flow — the actual + // QR code display is handled by the calling screen + const lnurlWithdrawUrl = `lightning:withdraw?amount=${transaction.amount.satAmount * 1000}&memo=Refund: ${transaction.id.slice(0, 8)}`; + + // Show the LNURL-withdraw QR code to the customer + Alert.alert( + 'Refund Ready', + `A Lightning withdraw request for ${transaction.amount.satAmount} points has been created.\n\nThe customer can scan the QR code or open the withdraw link in their Lightning wallet.`, + [ + { + text: 'Done — Refund Complete', + style: 'default', + onPress: () => { + dispatch(markRefunded(transaction.id)); + Alert.alert( + 'Refund Successful', + `${transaction.amount.satAmount} points refunded to ${transaction.merchant.username}.`, + ); + }, + }, + { + text: 'Cancel Refund', + style: 'cancel', + onPress: () => { + dispatch( + setRefunding({ + id: transaction.id, + isRefunding: false, + }), + ); + }, + }, + ], + ); + } catch (error) { + dispatch( + setRefunding({id: transaction.id, isRefunding: false}), + ); + Alert.alert( + 'Refund Failed', + 'Could not process the refund. Please try again.', + ); + } finally { + processingIds.current.delete(transaction.id); + } + }, + }, + ], + ); + }, + [dispatch, isRefundable], + ); + + return { + initiateRefund, + isRefunding: (id: string) => refundingIds.includes(id), + }; +}; + +export default useRefund; diff --git a/src/screens/TransactionHistory.tsx b/src/screens/TransactionHistory.tsx index f147654..8f87b79 100644 --- a/src/screens/TransactionHistory.tsx +++ b/src/screens/TransactionHistory.tsx @@ -11,7 +11,7 @@ import {PrimaryButton, SecondaryButton} from '../components'; // hooks import {useAppDispatch, useAppSelector} from '../store/hooks'; -import {usePrint} from '../hooks'; +import {usePrint, useRefund} from '../hooks'; // assets import Check from '../assets/icons/check.svg'; @@ -39,6 +39,7 @@ type FilterType = const TransactionHistory: React.FC = ({navigation: _navigation}) => { const navigations = useNavigation(); const {printReceipt} = usePrint(); + const {initiateRefund, isRefunding} = useRefund(); const dispatch = useAppDispatch(); const {transactions} = useAppSelector(state => state.transactionHistory); @@ -231,11 +232,35 @@ const TransactionHistory: React.FC = ({navigation: _navigation}) => { )} - {/* Minimal reprint button */} - onReprintTransaction(item)}> - - Reprint - + {/* Action buttons row */} + + {/* Refund button */} + {item.refunded ? ( + + Refunded + + ) : item.status === 'completed' && + item.amount.satAmount > 0 && + !item.refunded ? ( + initiateRefund(item)} + disabled={isRefunding(item.id)}> + + {isRefunding(item.id) ? 'Refunding...' : 'Refund'} + + + ) : null} + + {/* Reprint button */} + onReprintTransaction(item)}> + + Reprint + + ); }; @@ -488,6 +513,31 @@ const StatusIcon = styled.Image` tint-color: #007856; `; +const ActionsRow = styled.View` + flex-direction: row; + gap: ${scale(8)}px; +`; + +const RefundButton = styled.TouchableOpacity<{isRefunding: boolean}>` + flex-direction: row; + align-items: center; + justify-content: center; + background-color: ${props => + props.isRefunding ? '#f8f9fa' : '#FFF0F0'}; + border-radius: ${scale(6)}px; + padding: ${scale(8)}px; + flex: 1; +`; + +const RefundedBadge = styled.View` + align-items: center; + justify-content: center; + background-color: #f0f0f0; + border-radius: ${scale(6)}px; + padding: ${scale(8)}px; + flex: 1; +`; + const ReprintButton = styled.TouchableOpacity` flex-direction: row; align-items: center; @@ -495,6 +545,7 @@ const ReprintButton = styled.TouchableOpacity` background-color: #f8f9fa; border-radius: ${scale(6)}px; padding: ${scale(8)}px; + flex: 1; `; const ButtonIcon = styled.Image` diff --git a/src/store/slices/transactionHistorySlice.ts b/src/store/slices/transactionHistorySlice.ts index fbb6c8b..efddcb6 100644 --- a/src/store/slices/transactionHistorySlice.ts +++ b/src/store/slices/transactionHistorySlice.ts @@ -4,6 +4,7 @@ const initialState: TransactionHistoryState = { transactions: [], lastTransaction: undefined, maxTransactions: 50, // Keep last 50 transactions + refundingIds: [], }; export const transactionHistorySlice = createSlice({ @@ -47,6 +48,7 @@ export const transactionHistorySlice = createSlice({ clearTransactionHistory: state => { state.transactions = []; state.lastTransaction = undefined; + state.refundingIds = []; }, removeTransaction: (state, action: PayloadAction) => { @@ -60,6 +62,29 @@ export const transactionHistorySlice = createSlice({ ); } }, + + setRefunding: ( + state, + action: PayloadAction<{id: string; isRefunding: boolean}>, + ) => { + const {id, isRefunding} = action.payload; + if (isRefunding) { + if (!state.refundingIds.includes(id)) { + state.refundingIds.push(id); + } + } else { + state.refundingIds = state.refundingIds.filter(rid => rid !== id); + } + }, + + markRefunded: (state, action: PayloadAction) => { + const id = action.payload; + const transaction = state.transactions.find(t => t.id === id); + if (transaction) { + transaction.refunded = true; + } + state.refundingIds = state.refundingIds.filter(rid => rid !== id); + }, }, }); @@ -68,6 +93,8 @@ export const { updateTransactionStatus, clearTransactionHistory, removeTransaction, + setRefunding, + markRefunded, } = transactionHistorySlice.actions; export default transactionHistorySlice.reducer; diff --git a/src/types/transaction.d.ts b/src/types/transaction.d.ts index 1f00e55..46a23ba 100644 --- a/src/types/transaction.d.ts +++ b/src/types/transaction.d.ts @@ -13,6 +13,7 @@ type PaymentMethod = interface TransactionData { id: string; timestamp: string; + refunded?: boolean; // New fields for External Payment Rewards transactionType: TransactionType; // How the transaction was processed paymentMethod?: PaymentMethod; // How the customer paid (optional for backward compatibility) @@ -48,6 +49,7 @@ interface TransactionHistoryState { transactions: TransactionData[]; lastTransaction?: TransactionData; maxTransactions: number; + refundingIds: string[]; } interface ReceiptData {