Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +15,5 @@ export {
usePrint,
useNfc,
useFlashcard,
useRefund,
};
109 changes: 109 additions & 0 deletions src/hooks/useRefund.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
isRefunding: (id: string) => boolean;
}

const useRefund = (): RefundHookResult => {
const dispatch = useAppDispatch();
const refundingIds = useAppSelector(
state => state.transactionHistory.refundingIds,
);
const processingIds = useRef<Set<string>>(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;
63 changes: 57 additions & 6 deletions src/screens/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +39,7 @@ type FilterType =
const TransactionHistory: React.FC<Props> = ({navigation: _navigation}) => {
const navigations = useNavigation<NavigationProp>();
const {printReceipt} = usePrint();
const {initiateRefund, isRefunding} = useRefund();
const dispatch = useAppDispatch();
const {transactions} = useAppSelector(state => state.transactionHistory);

Expand Down Expand Up @@ -231,11 +232,35 @@ const TransactionHistory: React.FC<Props> = ({navigation: _navigation}) => {
)}
</CompactDetails>

{/* Minimal reprint button */}
<ReprintButton onPress={() => onReprintTransaction(item)}>
<ButtonIcon source={Refresh} />
<ButtonText>Reprint</ButtonText>
</ReprintButton>
{/* Action buttons row */}
<ActionsRow>
{/* Refund button */}
{item.refunded ? (
<RefundedBadge>
<ButtonText style={{color: '#999'}}>Refunded</ButtonText>
</RefundedBadge>
) : item.status === 'completed' &&
item.amount.satAmount > 0 &&
!item.refunded ? (
<RefundButton
isRefunding={isRefunding(item.id)}
onPress={() => initiateRefund(item)}
disabled={isRefunding(item.id)}>
<ButtonText
style={{
color: isRefunding(item.id) ? '#999' : '#FF6B6B',
}}>
{isRefunding(item.id) ? 'Refunding...' : 'Refund'}
</ButtonText>
</RefundButton>
) : null}

{/* Reprint button */}
<ReprintButton onPress={() => onReprintTransaction(item)}>
<ButtonIcon source={Refresh} />
<ButtonText>Reprint</ButtonText>
</ReprintButton>
</ActionsRow>
</TransactionCard>
);
};
Expand Down Expand Up @@ -488,13 +513,39 @@ 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;
justify-content: center;
background-color: #f8f9fa;
border-radius: ${scale(6)}px;
padding: ${scale(8)}px;
flex: 1;
`;

const ButtonIcon = styled.Image`
Expand Down
27 changes: 27 additions & 0 deletions src/store/slices/transactionHistorySlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const initialState: TransactionHistoryState = {
transactions: [],
lastTransaction: undefined,
maxTransactions: 50, // Keep last 50 transactions
refundingIds: [],
};

export const transactionHistorySlice = createSlice({
Expand Down Expand Up @@ -47,6 +48,7 @@ export const transactionHistorySlice = createSlice({
clearTransactionHistory: state => {
state.transactions = [];
state.lastTransaction = undefined;
state.refundingIds = [];
},

removeTransaction: (state, action: PayloadAction<string>) => {
Expand All @@ -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<string>) => {
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);
},
},
});

Expand All @@ -68,6 +93,8 @@ export const {
updateTransactionStatus,
clearTransactionHistory,
removeTransaction,
setRefunding,
markRefunded,
} = transactionHistorySlice.actions;

export default transactionHistorySlice.reducer;
Expand Down
2 changes: 2 additions & 0 deletions src/types/transaction.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -48,6 +49,7 @@ interface TransactionHistoryState {
transactions: TransactionData[];
lastTransaction?: TransactionData;
maxTransactions: number;
refundingIds: string[];
}

interface ReceiptData {
Expand Down