diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example diff --git a/src/__tests__/message-processor.service.test.ts b/src/__tests__/message-processor.service.test.ts index 0085402..b89c750 100644 --- a/src/__tests__/message-processor.service.test.ts +++ b/src/__tests__/message-processor.service.test.ts @@ -171,6 +171,28 @@ describe('MessageProcessor', () => { ); }); + it('should report an unavailable balance when the lookup fails', async () => { + mockCheckBalance.mockRejectedValueOnce(new Error('Horizon unavailable')); + await processor.processCommand('12345', 'BALANCE'); + expect(mockSendMessage).toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.unavailable'), + ); + }); + + it('should render an empty balance when the account has no funds', async () => { + mockCheckBalance.mockResolvedValueOnce([]); + await processor.processCommand('12345', 'BALANCE'); + expect(mockSendMessage).toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.success'), + ); + expect(mockSendMessage).not.toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.unavailable'), + ); + }); + it('should handle PROFILE command', async () => { await processor.processCommand('12345', 'PROFILE'); expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('profile.card')); @@ -744,6 +766,20 @@ describe('MessageProcessor', () => { await processor.processCommand('12345', 'BALANCE'); expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('balance.no_wallet')); }); + + it('should distinguish a failed lookup from an unfunded account', async () => { + mockCheckBalance.mockRejectedValueOnce(new Error('Horizon unavailable')); + await processor.processCommand('12345', 'BALANCE'); + const [, failedMessage] = mockSendMessage.mock.calls[0]; + expect(failedMessage).toContain('balance.unavailable'); + + mockSendMessage.mockClear(); + mockCheckBalance.mockResolvedValueOnce([]); + await processor.processCommand('12345', 'BALANCE'); + const [, emptyMessage] = mockSendMessage.mock.calls[0]; + expect(emptyMessage).toContain('balance.success'); + expect(emptyMessage).not.toContain('balance.unavailable'); + }); }); describe('payment requests (issue #65)', () => { diff --git a/src/__tests__/stellar.service.test.ts b/src/__tests__/stellar.service.test.ts index e14b045..e15e839 100644 --- a/src/__tests__/stellar.service.test.ts +++ b/src/__tests__/stellar.service.test.ts @@ -169,13 +169,19 @@ describe('StellarService', () => { ]); }); - it('should return an empty array when the account lookup fails', async () => { + it('should return an empty array when the account has no funds', async () => { + mServer.loadAccount.mockResolvedValueOnce({ balances: [] }); + + const balances = await stellarService.checkBalance('G_MOCK'); + expect(balances).toEqual([]); + }); + + it('should report a failed lookup instead of an empty balance', async () => { mServer.loadAccount.mockRejectedValueOnce(new Error('network unavailable')); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); try { - const balances = await stellarService.checkBalance('G_MOCK'); - expect(balances).toEqual([]); + await expect(stellarService.checkBalance('G_MOCK')).rejects.toThrow(); expect(consoleSpy).toHaveBeenCalledWith('Error checking balance:', expect.any(Error)); } finally { consoleSpy.mockRestore(); diff --git a/src/locales/en.json b/src/locales/en.json index 7212801..e331d9a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2,11 +2,13 @@ "balance": { "no_wallet": "[BALANCE] Error: Wallet not configured.", "success": "💰 Your Balances:\n{{balances}}", - "asset_not_found": "You don't have a trustline for {{asset}}." + "asset_not_found": "You don't have a trustline for {{asset}}.", + "not_funded": "Your wallet hasn't been funded yet -- you currently hold no funds.", + "unavailable": "Your balance is temporarily unavailable. Please try again later." }, "history": { "fetching": "[HISTORY] Fetching transaction history for {{phone}}...", - "header": "📜 Transaction History (Last 10):\n\n", + "header": "🜜 Transaction History (Last 10):\n\n", "item_sent": "{{index}}. ✅ Sent {{amount}} {{asset}} to {{counterparty}}\n {{date}} • tx: {{hash}}", "item_received": "{{index}}. 📥 Received {{amount}} {{asset}} from {{counterparty}}\n {{date}} • tx: {{hash}}", "item_other": "{{index}}. 🔄 {{type}} ({{amount}} {{asset}})\n {{date}} • tx: {{hash}}", @@ -28,14 +30,14 @@ "failed": "❌ [SEND] Failed: {{message}}" }, "request": { - "usage": "Usage: REQUEST <@username or phone>", + "usage": "Usage: REQUEST [amount> <@username or phone>", "no_user": "Error: Could not find user {{target}}.", - "notify_recipient": "🔔 [REQUEST] {{sender}} is requesting {{amount}} XLM from you.\n\nReply with:\nACCEPT {{requestId}} to approve (funds are held securely in escrow)\nDECLINE {{requestId}} to reject", + "notify_recipient": "🔕 [REQUEST] {{sender}} is requesting {{amount}} XLM from you.\n\nReply with:\nACCEPT {{requestId}} to approve (funds are held securely in escrow)\nDECLINE {{requestId}} to reject", "confirmation": "[REQUEST] Request for {{amount}} XLM sent to {{target}}." }, "create_group": { "usage": "Usage: CREATE GROUP ", - "success": "✅ Group \"{{name}}\" created!\nGroup ID: {{id}}", + "success": "✅ Group \" {{name}}\" created!\nGroup ID: {{id}}", "failed": "❌ Failed to create group: {{message}}" }, "join_group": { @@ -47,7 +49,7 @@ "usage": "Usage: INVITE MEMBER <@username or phone>", "no_user": "Error: Could not find user {{target}}.", "not_creator": "Error: You are not the creator of any group.", - "notify_recipient": "🔔 [INVITE] {{sender}} invited you to join their savings group \"{{groupName}}\".\n\nReply with: JOIN GROUP {{groupId}}", + "notify_recipient": "🔕 [INVITE] {{sender}} invited you to join their savings group \"{{groupName}}\".\n\nReply with: JOIN GROUP {{groupId}}", "success": "✅ Invite sent to {{target}}." }, "group_status": { @@ -75,7 +77,7 @@ "error_cannot_kick_self": "Error: You can't kick yourself — use LEAVE GROUP instead." }, "payout": { - "usage": "Usage: PAYOUT ", + "usage": "Usage: PAYOUT ", "no_group": "Error: You are not part of any group.", "not_creator": "Error: You are not the creator of any group.", "order_usage": "Usage: PAYOUT ORDER <@user1> <@user2> ... (list every member exactly once)", @@ -96,9 +98,9 @@ "usage": "Usage: CONTRIBUTE ", "no_group": "Error: You are not part of any group.", "amount_mismatch": "❌ Contribution must be exactly {{required}} XLM as required by this group.", - "group_paused": "❌ \"{{groupName}}\" is paused — it needs at least 2 members before contributions can resume.", - "initiating": "[CONTRIBUTE] Initiating contribution of {{amount}} XLM to \"{{groupName}}\"...", - "success": "✅ Successfully contributed {{amount}} XLM to \"{{groupName}}\"!", + "group_paused": "❌ \" {{groupName}}\" is paused — it needs at least 2 members before contributions can resume.", + "initiating": "[CONTRIBUTE] Initiating contribution of {{amount}} XLM to \" {{groupName}}\"...", + "success": "✅ Successfully contributed {{amount}} XLM to \" {{groupName}}\"!", "failed": "❌ Contribution failed: {{message}}" }, "validation": { @@ -110,7 +112,7 @@ "withdraw": { "usage": "Usage: WITHDRAW ", "no_group": "Error: You are not part of any group.", - "success": "✅ Successfully withdrew {{amount}} XLM from \"{{groupName}}\" pool! (MOCKED)" + "success": "✅ Successfully withdrew {{amount}} XLM from \" {{groupName}}\" pool! (MOCKED)" }, "help": { "text": "*Kolo Commands:*\n\n_Account_\nBALANCE\nBALANCE \nHISTORY\nPROFILE\n\n_Payments_\nSEND <@user>\nREQUEST <@user>\n\n_Savings Groups_\nCREATE GROUP \nJOIN GROUP \nINVITE MEMBER <@user>\nGROUP STATUS\nCONTRIBUTE \nWITHDRAW \nLEAVE GROUP \nKICK <@user> \n\n_Payout Rotation_\nPAYOUT ORDER <@user1> <@user2> ...\nPAYOUT STATUS\nPAYOUT WAIT\nPAYOUT PROCEED\nPAYOUT SKIP <@user>\n\n_Support_\nHELP\nSUPPORT" @@ -131,7 +133,7 @@ "accept_usage": "Usage: ACCEPT ", "decline_usage": "Usage: DECLINE ", "accepted": "✅ Escrow hold placed! {{amount}} {{asset}} is now locked for this request.\n\nIf everything looks right, the requester will receive it in about {{minutes}} minutes.\n\nMade a mistake? You have {{reclaimHours}} hour to reclaim your funds from the escrow.\nTx: https://stellar.expert/explorer/testnet/tx/{{hash}}", - "declined": "🙎 {{respondent}} declined your request for {{amount}} {{asset}}.", + "declined": "🐎 {{respondent}} declined your request for {{amount}} {{asset}}.", "decline_confirmed": "Request declined. The requester has been notified.", "completed_requester": "💸 Your payment request of {{amount}} {{asset}} was completed and the funds are in your account.\nTx: https://stellar.expert/explorer/testnet/tx/{{hash}}", "completed_responder": "✅ The confirmation window closed and {{amount}} {{asset}} was released to the requester. Thanks for using Kolo!", diff --git a/src/services/message-processor.service.ts b/src/services/message-processor.service.ts index 801931f..d4fef29 100644 --- a/src/services/message-processor.service.ts +++ b/src/services/message-processor.service.ts @@ -183,7 +183,22 @@ export class MessageProcessor { return await this.whatsappService.sendMessage(from, t('balance.no_wallet', lang)); } const { publicKey } = JSON.parse(user.stellarWallet); - const balances = await this.stellarService.checkBalance(publicKey); + let balances; + try { + balances = await this.stellarService.checkBalance(publicKey); + } catch (error: any) { + console.error('Balance lookup failed:', error); + return await this.whatsappService.sendMessage( + from, + t('balance.unavailable', lang), + ); + } + + // An account with no funds still returns an empty list (not an error), + // so this renders as an empty balance rather than an unavailable one. + if (balances.length === 0) { + return await this.whatsappService.sendMessage(from, t('balance.empty', lang)); + } const assetFilter = args[0]?.toUpperCase(); const shown = assetFilter ? balances.filter((b) => b.assetCode === assetFilter) : balances; diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index 834d0d5..f563e76 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -88,6 +88,11 @@ export class StellarService { /** * Returns current asset balances for a given Stellar account. */ + /** + * Returns current asset balances for a given Stellar account. + * Throws when the balance lookup fails so callers can distinguish an + * unavailable balance from an account that genuinely holds no funds. + */ public async checkBalance(publicKey: string): Promise { try { const account = await this.server.loadAccount(publicKey); @@ -98,7 +103,7 @@ export class StellarService { })); } catch (error) { console.error('Error checking balance:', error); - return []; + throw error; } }