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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions src/controllers/bot.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface WhatsAppContact {
wa_id?: string;
profile?: {
name?: string;
locale?: string;
};
}

Expand Down Expand Up @@ -93,12 +94,11 @@ export class BotController {
return null;
}

// Try to extract locale if available in contacts metadata for this sender (e.g., from WhatsApp profile)
let locale: string | undefined;
// Carry the sender's locale through the queue so every downstream
// rejection (including amount validation) can be rendered in the
// user's language rather than as English prose.
const contact = contacts?.find(c => c.wa_id === from);
if (contact && (contact as any).profile?.locale) {
locale = (contact as any).profile.locale;
}
const locale = contact?.profile?.locale;

return { from, msgBody, whatsappMessageId: id, locale };
}
Expand Down
3 changes: 3 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@
"precision_exceeded": "❌ Amount cannot have more than 7 decimal places.",
"exceeds_max": "❌ Amount exceeds the maximum allowed (1,000,000 XLM)."
},
"group": {
"invalid_amount": "❌ Invalid contribution amount: {{reason}}"
},
"withdraw": {
"usage": "Usage: WITHDRAW <amount>",
"no_group": "Error: You are not part of any group.",
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"validation": {
"invalid_format": "❌ Format de montant invalide. Veuillez entrer un nombre positif (par ex. 10 ou 10.50).",
"zero_amount": "❌ Le montant doit être supérieur à zéro.",
"not_positive": "❌ Le montant doit être un nombre positif supérieur à zéro.",
"precision_exceeded": "❌ Le montant ne peut pas avoir plus de 7 décimales.",
"exceeds_max": "❌ Le montant dépasse le maximum autorisé (1 000 000 XLM)."
},
Expand Down
5 changes: 4 additions & 1 deletion src/locales/ha.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@
"invalid_format": "❌ Tsarin adadi ba daidai bane. Don Allah sa lambar da ta wuce sifili (misali, 10 ko 10.50).",
"zero_amount": "❌ Dole adadi ya wuce sifili.",
"precision_exceeded": "❌ Ba za a iya samun adadi fiye da maki na 7 bayan ɗigo ba.",
"exceeds_max": "❌ Adadin ya wuce yawan da aka yarda da shi (1,000,000 XLM)."
"exceeds_max": "❌ Adadin ya wuce yawan da aka yarda da shi (1,000,000 XLM).",
"not_positive": "❌ Dole adadi ya zama lamba mai kyau da ta wuce sifili.",
"too_many_decimals": "❌ Adadi ba zai iya samun fiye da maki na 7 bayan ɗigo ba.",
"exceeds_maximum": "❌ Adadin ya wuce iyakar da aka yarda da shi (1,000,000 XLM)."
},
"withdraw": {
"usage": "Amfani: WITHDRAW <adadi>",
Expand Down
3 changes: 2 additions & 1 deletion src/locales/ig.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@
"invalid_format": "❌ Ụdị ọnụọgụ adịghị mma. Biko tinye nọmba karịrị efu (dịka, 10 ma ọ bụ 10.50).",
"zero_amount": "❌ Ọnụọgụ ga-akarịrị efu.",
"precision_exceeded": "❌ Ọnụọgụ enweghị ike inwe karịa mkpụrụ 7 mgbe akara iri gasịrị.",
"exceeds_max": "❌ Ọnụọgụ karịrị nke kachasị anabata (1,000,000 XLM)."
"exceeds_max": "❌ Ọnụọgụ karịrị nke kachasị anabata (1,000,000 XLM).",
"positive_required": "❌ Ọnụọgụ ntinye ụtụ ga-abụ nọmba dị mma karịrị efu."
},
"withdraw": {
"usage": "Ojiji: WITHDRAW <ọnụọgụ>",
Expand Down
16 changes: 8 additions & 8 deletions src/locales/pcm.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
},
"history": {
"fetching": "[HISTORY] We dey find your transaction history for {{phone}}...",
"header": "📜 Your Transactions (Last 10):\n\n",
"item_sent": "{{index}}. ✅ You send {{amount}} {{asset}} go {{counterparty}}\n {{date}} • tx: {{hash}}",
"header": "🜜 Your Transactions (Last 10):\n\n",
"item_sent": "{{index}}. ★ You send {{amount}} {{asset}} go {{counterparty}}\n {{date}} • tx: {{hash}}",
"item_received": "{{index}}. 📥 You collect {{amount}} {{asset}} from {{counterparty}}\n {{date}} • tx: {{hash}}",
"item_other": "{{index}}. 🔄 {{type}} ({{amount}} {{asset}})\n {{date}} • tx: {{hash}}",
"more": "\nReply HISTORY MORE to see older transactions",
Expand All @@ -20,22 +20,22 @@
"card": "*Kolo Profile*\nPhone: {{phone}}\nUsername: {{username}}\nWallet: {{wallet}}\nJoined: {{joined}}"
},
"send": {
"usage": "How to use: SEND <amount> <@username or phone>",
"usage": "How to use: SELD <amount> <@username or phone>",
"no_wallet": "Error: You never setup your wallet.",
"no_recipient": "Error: We no see wallet for {{target}}.",
"initiating": "[SEND] We dey try send {{amount}} XLM go {{target}}...",
"success": "✅ You don successfully send {{amount}} XLM go {{target}}!",
"failed": "❌ [SEND] E fail: {{message}}"
},
"request": {
"usage": "How to use: REQUEST <amount> <@username or phone>",
"usage": "How to use: REQUEST [amount> <@username or phone>",
"no_user": "Error: We no find user {{target}}.",
"notify_recipient": "🔔 [REQUEST] {{sender}} dey ask you for {{amount}} XLM. Reply with: SEND {{amount}} {{senderPhone}}",
"notify_recipient": "🔔 [REQUEST] {{sender}} dey ask you for {{amount}} XLM. Reply with: SELD {{amount}} {{senderPhone}}",
"confirmation": "[REQUEST] We don send request for {{amount}} XLM go {{target}}."
},
"create_group": {
"usage": "How to use: CREATE GROUP <name> <amount> <frequency>",
"success": "✅ Group \"{{name}}\" don ready!\nGroup ID: {{id}}",
"success": "✅ Group \" {{name}}\" don ready!\nGroup ID: {{id}}",
"failed": "❌ We no fit create group: {{message}}"
},
"join_group": {
Expand Down Expand Up @@ -93,7 +93,7 @@
"success": "✅ You don successfully withdraw {{amount}} XLM from \"{{groupName}}\"! (MOCKED)"
},
"help": {
"text": "*Kolo Commands:*\n\n_Account_\nBALANCE\nBALANCE <asset>\nHISTORY\nPROFILE\n\n_Payments_\nSEND <amount> <@user>\nREQUEST <amount> <@user>\n\n_Savings Groups_\nCREATE GROUP <name> <amount> <frequency>\nJOIN GROUP <groupId>\nINVITE MEMBER <@user>\nGROUP STATUS\nCONTRIBUTE <amount>\nWITHDRAW <amount>\n\n_Payout Rotation_\nPAYOUT ORDER <@user1> <@user2> ...\nPAYOUT STATUS\nPAYOUT WAIT\nPAYOUT PROCEED\nPAYOUT SKIP <@user>\n\n_Support_\nHELP\nSUPPORT"
"text": "*Kolo Commands:*\n\n_Account_\nBALANCE\nBALANCE <asset>\nHISTORY\nPROFILE\n\n_Payments_\nSEND <amount> <@user>\nREQUEST [amount> <@user>\n\n_Savings Groups_\nCREATE GROUP <name> <amount> <frequency>\nJOIN GROUP <groupId>\nINVITE MEMBER <@user>\nGROUP STATUS\nCONTRIBUTE <amount>\nWITHDRAW <amount>\n\n_Payout Rotation_\nPAYOUT ORDER <@user1> <@user2> ...\nPAYOUT STATUS\nPAYOUT WAIT\nPAYOUT PROCEED\nPAYOUT SKIP <@user>\n\n_Support_\nHELP\nSUPPORT"
},
"unknown": {
"command": "I no understand that command. Send HELP to see commands wey dey work."
Expand All @@ -109,4 +109,4 @@
"wallet": {
"usdc_trustline_low_reserve": "You need at least 2 XLM for your wallet make you fit use USDC."
}
}
}
27 changes: 3 additions & 24 deletions src/services/group.service.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { validateAmount } from './amount-validator';
import { scheduleGroupCycle, removeGroupCycle } from '../queue/contribution-scheduler.queue';
import { enqueueContractDeployment } from '../queue/soroban-deployment.queue';

const prisma = new PrismaClient();

export class GroupService {
/**
* Validates a contribution amount at the service boundary — the last line
* of defence before a value reaches the database or the blockchain.
*
* Throws with a descriptive message so callers can surface it to the user.
*/
private static validateAmount(amount: string | Prisma.Decimal): void {
const numericValue = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
if (isNaN(numericValue) || numericValue <= 0) {
throw new Error('Contribution amount must be a positive number greater than zero.');
}
if (typeof amount === 'string') {
const decimalPart = amount.includes('.') ? amount.split('.')[1] : '';
if (decimalPart.length > 7) {
throw new Error('Amount cannot have more than 7 decimal places.');
}
}
if (numericValue > 1_000_000) {
throw new Error('Amount exceeds the maximum allowed (1,000,000 XLM).');
}
}

public async createGroup(userId: string, name: string, amount: string | Prisma.Decimal, frequency: string) {
GroupService.validateAmount(amount);
validateAmount(amount);
const group = await prisma.savingsGroup.create({
data: {
name,
Expand Down Expand Up @@ -113,7 +92,7 @@ export class GroupService {
}

public async addContribution(userId: string, groupId: string, amount: string | Prisma.Decimal, txHash: string, status: 'COMPLETED' | 'PENDING' | 'FAILED' = 'COMPLETED') {
GroupService.validateAmount(amount);
validateAmount(amount);
return await prisma.contribution.create({
data: {
userId,
Expand Down
16 changes: 1 addition & 15 deletions src/services/message-processor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,7 @@ export class MessageProcessor {
* - Must not exceed 1,000,000 XLM (guards against fat-finger errors)
*/
private validateAmount(amountStr: string): string | null {
if (!/^\d+(\.\d+)?$/.test(amountStr)) {
return 'validation.invalid_format';
}
const value = parseFloat(amountStr);
if (value <= 0) {
return 'validation.zero_amount';
}
const decimalPart = amountStr.includes('.') ? amountStr.split('.')[1] : '';
if (decimalPart.length > 7) {
return 'validation.precision_exceeded';
}
if (value > 1_000_000) {
return 'validation.exceeds_max';
}
return null;
return GroupService.validateAmount(amountStr);
}

public async processCommand(from: string, text: string, locale?: string) {
Expand Down