September 27, 2024

Fundraising tools for non-profits

Are you running or working for a non-profit?
Do you struggle with managing donors, donations and communication?
Let's look at how digital tools can simplify your life.

Cloud API

Fundraising 101

If you ever donated to a non-profit organization in Slovakia, there's a good chance you did so through the portal https://www.darujme.sk. Thanks to this portal, non-profit organizations can receive payments quickly and securely. It is a well-known and trusted platform offering various payment options. Additionally, donations can be made either as a one-time contribution or on a recurring basis, and this applies to both individuals and companies.

Non-profits can access information about donations and donors through a web application or via API.

Why is this important?

So that organizations can communicate with donors, allowing donors to see how their financial contributions are creating a better future and helping to fulfill the organization's goals. Simply put, transparent and effective communication leads to a better relationship with donors, which in turn can result in a stable or even increasing volume of donations.

Effective communication is automated

Donation information is consolidated into a single database where it is systematically evaluated – donors are categorized, and communication is then initiated based on their preferences. It may sound complicated, but the idea is simple: for instance, regular donors receive information more frequently, while one-time donors hear back after a longer period. It makes sense for organizations to contact major or corporate donors personally.

The popular choice for the single database is https://salesforce.com. Especially since they offer great discounts for non-profits (up to free).

In this blog we will show how to connect Salesforce.com and Darujme.sk.

The blog will be highly technical. However it is a complete recipe, so if your organization lacks technical team members we can help you deploy it.

Integration Overview

  1. Integration will be built inside salesforce.com, as an scheduled APEX class.
  2. We will use the darujme.sk API to fetch payments.
  3. Each payment will be turned into a InuTransaction and if the donor does not yet exists in our database into a Contact and Account.
  4. Contact will represent a natural person's donation, and we will match by email.
  5. Account will represent a company donation.

Data Model

We will a single new Salesforce object for storing donations - called InuTransaction. Naturally we need fields for the financial amount, date and donor. Since the donation can be made by a natural person, we will add Lookups to both Contact and Account object. Account - a company - is optional.
Contact is mandatory, in fact in is Master-Detail lookup. This is required for rollup fields we will want to create on contacts, such as total dotations per year, average donation size, etc.

Let's review the whole list:

FIELD LABEL FIELD NAME DATA TYPE
Account Account__c Lookup(Account)
Bank Account Name Bank_Account_Name__c Text(100)
Campaign Campaign__c Lookup(Campaign)
Campaign Name Campaign_Name__c Text(100)
Contact Contact__c Master-Detail(Contact)
Created By CreatedById Lookup(User)
darujme_id darujme_id__c Text(100)
Donor Kind Donor_Kind__c Text(100)
Info Text Info_Text__c Text(100)
Last Modified By LastModifiedById Lookup(User)
Payer IBAN Payer_IBAN__c Text(100)
Specific Number Specific_Number__c Text(100)
Transaction Name Auto Number
Transaction Amount Transaction_Amount__c Currency(16, 2)
Transaction Date Transaction_Date__c Date
Transaction Periodicity Transaction_Periodicity__c Text(100)
Transaction Sequence no Transaction_Sequence_no__c Number(18, 0)
Transaction_Status Transaction_Status__c Text(100)
Variable Number Variable_Number__c Text(100)
Donation Source Zdroj_daru__c Picklist

Side note: This object already hsa fields we will need for handling donations made by wire transfer (IBAN, etc.). Keep an eye out for Part 2 of this series.

Calling Darujme.sk API from APEX

The darujme.sk API is quite straightforward.
Every non-profit that has an darujme.sk account can ask for API access and will get a api-key and secret.

Before we can call any other APIs we need to exchange the user's name and password for an access token.
So let's do that.

global class InukoDarujmeSync implements Schedulable {
	public class TokenResponse {
        public TokenResponseInner response;
    } 
    public class TokenResponseInner {
        public String token;
    }
	private static string getDarujmeToken() {
		object r = darujmeFetch('POST', '/v1/tokens/', '{"username": "'+DARUJME_USER+'", "password": "'+DARUJME_PWD+'"}', null, null);
		TokenResponse resp = (TokenResponse)JSON.deserialize((string)r, TokenResponse.class);
		string token = resp.response.token;
		return token;
	}
}

The darujmeFetch method adds all the required headers common for all API calls and executes an HTTP request.
Only the generateSignature method is somewhat unusual - it uses the API_SECRET to calculate a signature from the api path and post data (if any). Is is just an SHA256 hash of the above. As I said only somewhat unusual.

global class InukoDarujmeSync implements Schedulable {
  private static object darujmeFetch(string method, string api, string sb, string token, string apiParams) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.darujme.sk'+api+(apiParams != null ?apiParams:''));
        req.setMethod(method);   
        if (token != null)
        	req.setHeader('Authorization', 'TOKEN '+token);
        
        req.setHeader('Content-Type', 'application/json');
		req.setHeader('Accept','application/json');
		req.setHeader('X-ApiKey', DARUJME_API_KEY);
		req.setHeader('X-Organisation', DARUJME_ORGANIZATION_ID);
		req.setHeader('X-Signature', generateSignature(api, sb));
        
        if(method == 'POST')
            req.setBody(sb);
        
        Http http = new Http();
        if (Test.isRunningTest()) {
           // return test data.
        } else {
        	HTTPResponse res = http.send(req);
        	object body = res.getBody();
        	return body;
        }
   }
   private static string generateSignature(string api, string sb) {
        string secret = DARUJME_API_SECRET;
        if (sb == null)
            sb = '';
        string payload = sb + ':' + api;
        string mac = generateHmacSHA256Signature(payload, secret);
        return mac;
    }
    
    private static String generateHmacSHA256Signature(String payload, String secretKeyValue) {
        String algorithmName = 'HmacSHA256';
        Blob hmacData = Crypto.generateMac(algorithmName, Blob.valueOf(payload), Blob.valueOf(secretKeyValue));
        return EncodingUtil.convertToHex(hmacData);
    }
}

Calling Darujme.sk API from Javascript

Below is the same code in Javascript/NodeJS just in case you will want to mass export/import historic records.

const getDarujmeToken = ()=>{
	const body = {
		username: DARUJME_USER,
		password: DARUJME_PWD
	}
	const tokenResp = await darujmeFetch('POST', '/v1/tokens/', body, undefined);
	if (!tokenResp)
		throw Error("No token!");
	const token = tokenResp.response.token;
	return token;
}

const darujmeFetch = async (method, api, body, token, apiParams) => {

	let sb = "";
	if (body)
		sb = JSON.stringify(body);

	const init = {
		headers: {
			'Content-Type': 'application/json',
			'Accept': 'application/json',
			'X-ApiKey': DARUJME_API_KEY,
			'X-Organisation': DARUJME_ORGANIZATION_ID, // Guid can be taken from web - org info url
			'X-Signature': generateSignature(api, sb),
		},
		method: method
	};
	if (token) {
		init.headers["Authorization"] = 'TOKEN ' + token;
	}

	if (method === "POST" && sb) {
		init.body = sb;
	}

	const url = "https://api.darujme.sk"
	const reqUrl = url + api + (apiParams||"");
	const resp = await fetch(reqUrl, init);
	if (!resp.ok) {
		const t = await resp.text();
		throw Error(t);
	} else {
		const j = await resp.json();
		return j;
	}
}
const generateSignature = (api, sb) => {
	const path = api;
	const payload = sb + ":" + path;
	const secret = DARUJME_API_SECRET;
	const r = crypto.createHmac('sha256', Buffer.from(secret)).update(Buffer.from(payload)).digest("hex");
	return r;
}

Schedule APEX

The code below is conceptually simple. It fetches donations from darujme.sk and creates corresponding objects in salesforce.com.

  1. We will call the payment api and request all new donations since yesterday. We expect the code to be called daily. Since we keep the darujme.sk donation ids in salesforce db, we can quickly skip donations we already processes.

  2. We look for donations made by companies, and we will create corresponding accounts if they don't already exists.

  3. We look for contacts using the donor's email, and we will create contacts if the email is not already in salesforce.

  4. Finally we create InuTransaction records, one for each donation.

The reason why the code is structured in this way is simple. The apex code only make 100 soql / dml calls, thus we cannot query/create each record separately. (Well, we can, it just wouldn't be very performant.)

First some objects used for API response JSON deserialization. Now there are more fields provided by the API, but these are the ones we use.

global class InukoDarujmeSync implements Schedulable {
    
    public class TokenResponse {
        public TokenResponseInner response;
    } 
    public class TokenResponseInner {
        public String token;
    }
    
    public class PaymentResponse {
        public PaymentItem[] items; 
        public PaymentMeta metadata;
    }
    public class PaymentMeta {
        Integer page, pages, total;
    }
    public class PaymentItem {
        public string id, status, created_at, happened_at;
        public Decimal value;
        public Integer sequence_no;
        public PaymentDonation donation;
    }
    public class PaymentDonation {
        public string periodicity, kind;
        public PaymentDonor donor;
        public PaymentCompany company_data;
    }
    public class PaymentDonor {
        public string id,email, name,surname,note;
    } 
    public class PaymentCompany {
        public string tax_id, vat_id, business_id, business_name, business_address;
    } 
}

Now let's login and fetch donations (payments). Then we will filter any donations we already have in salesforce.

global class InukoDarujmeSync implements Schedulable {
	global void execute(SchedulableContext SC) {       
        String token = getDarujmeToken();
       
        String since = datetime.now().addDays(-1).formatGMT('yyyy-MM-dd');
        String apiParams = '?created_gte='+since+'T00%3A00%3A00Z&limit=100';

 		for (Integer i=1; i<1000; i++)
        {
            String apiParams2 = apiParams + '&page='+i;
            object z = darujmeFetch('GET', '/v1/payments/', '', token, apiParams2);
            PaymentResponse payments = (PaymentResponse)JSON.deserialize((string)z, PaymentResponse.class);
            
            Map<string, PaymentItem> itemsMap = new Map<string, PaymentItem>();
            for (PaymentItem item : payments.items) {
                String email = item.donation.donor.email;
                item.donation.donor.email = email.toLowerCase(); // just in case...
                itemsMap.put(item.id, item);
            }
           
            Set<string> tids = itemsMap.keySet();
            for (InuTransaction__c t : [SELECT Id, darujme_id__c From InuTransaction__c where darujme_id__c = :tids]) {
                itemsMap.remove(t.darujme_id__c);
                system.debug('SKIP Payment with id already exists:'+t.darujme_id__c);
            }
            List<PaymentItem> paymentItems = itemsMap.values();
            
            if (paymentItems.size() > 0) {
            
                Map<string, string> accounts = prepareAccounts(paymentItems);
                
                Map<string, string> contacts = prepareContacts(paymentItems, accounts);
                
                processPayments(paymentItems, accounts, contacts);
                break; // We have to break here, because we cannot make HTTP calles in APEX with uncommited data. 
				// If this actually ever happends we will have to schedule more APEX jobs 
            }
            // Processes all
            if (payments.metadata == null || payments.metadata.page >= payments.metadata.pages)
                break;
        }
    }
}

If there are any donations to process paymentItems.size() > 0, we will create accounts for company donations (if any). We also return a cache for translating company names to salesforce accountids.

	// Returns a Map of name (and ico) to accountId. 
    // First find the accounts by payments donor company name.
    // If not found create the accounts.
    private static Map<string, string> prepareAccounts(List<PaymentItem> paymentsItems) {
        Map<string, PaymentItem> emap = new Map<string, PaymentItem>();
        for (PaymentItem item : paymentsItems) {
            PaymentCompany company = item.donation.company_data;
            if (item.donation.kind == 'company' && company != null) {
                if (company.business_name != null)
                    emap.put(company.business_name, item);
                if (company.business_id != null)
                    emap.put(company.business_id, item);
            }
          
        }
        Map<string, string> accounts = new Map<string, string>(); // companyName -> account.Id
        
        // 1. Find existing accounts by name
        Set<string> names = emap.keySet();
        for (Account l : [SELECT Id, Name, AccountNumber From Account where name = :names OR AccountNumber= :names ]) {
            system.debug('SKIP: account:'+l.Name);
            if (l.Name != null) {
                accounts.put(l.Name, l.id);
                PaymentItem x = emap.get(l.Name);
                if (x != null)
                    emap.remove(x.donation.company_data.business_id);
                emap.remove(l.Name);
            }
            if (l.AccountNumber != null) {
                accounts.put(l.AccountNumber, l.id);
                PaymentItem x = emap.get(l.AccountNumber);
                if (x != null)
                    emap.remove(x.donation.company_data.business_name);
                emap.remove(l.AccountNumber);
            }
        }
        
        // 2. Create missing accounts
        List<Account> accountsToCreate = new List<Account>();
        Set<PaymentItem> itemsWithoutAccount = new Set<PaymentItem>(emap.values()); // remove duplicates
        for (PaymentItem item : itemsWithoutAccount) {
            
            PaymentCompany company = item.donation.company_data;
            if (item.donation.kind == 'company' && company != null && (company.business_name != null || company.business_id != null)) {
              	Account aid = new Account(Name=company.business_name, AccountNumber=company.business_id, ICO__c=company.business_id, IC_DPH__c=company.vat_id, Tax_ID__c = company.tax_id);
                if (company.business_address != null) {
                    aid.BillingStreet = company.business_address;
                }
                system.debug('CREATE: account:'+company.business_name+' ico:'+company.business_id);
           		accountsToCreate.add(aid);
            }
        }
       	insert accountsToCreate;
        for (Account c : accountsToCreate) {
            if (c.Name != null)
            	accounts.put(c.Name, c.Id);
            if (c.AccountNumber != null) {
                 accounts.put(c.AccountNumber, c.Id);
            }
        }
        
        return accounts;
    }

Now let's create salesforce contacts for donors if they don't already exists. Again we will return a cache of email to salesforce contact id. Observe that we use the accounts cache to put the contact under the company account if the donation was by a company.

    // Returns a Map of email to contactid. 
    // First find the contacts by payments donor email.
    // If not found create the contacts.
    private static Map<string, string> prepareContacts(List<PaymentItem> paymentsItems, Map<string, string> accounts) {
        Map<string, PaymentItem> emap = new Map<string, PaymentItem>();
        for (PaymentItem item : paymentsItems) {
        	emap.put(item.donation.donor.email, item);
        }
        Map<string, string> contacts = new Map<string, string>(); // email -> contact.Id
        
        // 1. Find existing contacts by email
        Set<string> emails = emap.keySet();
        for (Contact l : [SELECT Id, Name, accountid,email,Secondary_Email__c From Contact where email = :emails or Secondary_Email__c = :emails]) {
            system.debug('SKIP: contact:'+l.Name);
            if (l.email != null) {
                contacts.put(l.email, l.id);
                emap.remove(l.email);
            }
            if (l.Secondary_Email__c != null) {
                contacts.put(l.Secondary_Email__c, l.id);
                emap.remove(l.Secondary_Email__c);
            }
        }
        
        // 2. Create missing contacts
        string defaultAccount = null;
        List<Contact> contactsToCreate = new List<Contact>();
        Set<PaymentItem> itemsWithoutContact = new Set(emap.values());
        for (PaymentItem item : itemsWithoutContact) {
            string aid = null;
            
            PaymentCompany company = item.donation.company_data;
            
            if (accounts != null && item.donation.kind == 'company' && company != null && (company.business_name != null || company.business_id != null)) {
                if (company.business_name != null)
                	aid = accounts.get(company.business_name);
                if (aid == null && company.business_id != null)
               		aid = accounts.get(company.business_id);
            }
            if (aid == null) {
                if (defaultAccount == null)
                    defaultAccount = getDefaultAccount();
                aid = defaultAccount;
            }
            system.debug('CREATE: contact:'+item.donation.donor.name+' email:'+item.donation.donor.email);
            contactsToCreate.add(new Contact(firstname=item.donation.donor.name, lastname=item.donation.donor.surname, email=item.donation.donor.email, AccountId=aid));
        }
       	insert contactsToCreate;
        for (Contact c : contactsToCreate) {
            contacts.put(c.Email.toLowerCase(), c.Id);
        }
        
        return contacts;
    }
	private static string getDefaultAccount() {
        Account defaultAccount = null;
        String defaultAccountName = 'Fyzická osoba'; // Natural Persons
        List<Account> accs = [SELECT Id, Name From Account where name = :defaultAccountName];
        if (accs.size()>0) {
            return accs[0].Id;
        } else {
            Account d = new Account(name = defaultAccountName);
            insert d;
            return d.Id;
        }
    }

Armed with cached information, the grand finale is just a simple loop.

	private static void processPayments(List<PaymentItem> paymentItems, Map<string, string> accounts, Map<string, string> contacts) {
        List<InuTransaction__c> trans = new List<InuTransaction__c>();
        for(PaymentItem item : paymentItems) {
            String email = item.donation.donor.email;
            Decimal value = item.value;
           
            String aid = null;
            PaymentCompany company = item.donation.company_data;
            if (item.donation.kind == 'company' && company != null) {
                if (company.business_name != null)
                    aid = accounts.get(company.business_name);
                if (aid == null && company.business_id != null)
                    aid = accounts.get(company.business_id);
            }
            
            String cid = contacts.get(email);
            
            InuTransaction__c t = new InuTransaction__c(contact__c = cid);
            if (aid != null)
                t.account__c = aid;
            t.Info_Text__c = 'Darujme';
            t.Zdroj_daru__c = 'Darujme';
            t.darujme_id__c = item.id;
            t.Donor_kind__c = item.donation.kind;
            t.Transaction_Amount__c = value;
            t.Transaction_Status__c = item.status;
            t.Transaction_Sequence_no__c = item.sequence_no;
            t.Transaction_Periodicity__c = item.donation.periodicity;
            string[] dt_parts = item.happened_at.split('T')[0].split('-'); // We only want to date part.
            t.Transaction_Date__c = Date.newInstance(integer.valueOf(dt_parts[0]),integer.valueOf(dt_parts[1]),integer.valueOf(dt_parts[2]));
            system.debug('CREATE: transaction:'+email+' kind:'+item.donation.kind+' value:'+value);
            trans.add(t);
        }
        insert trans;
    }

Scheduling the Job

The only thing left is to actually periodically call the code above. We we will setup in salesforce 'Scheduled Jobs' (cron).

Scheduled_Jobs_Apex

In case you need to process more than a hundren donations daily, you might need to create multiple daily jobs. Or contact us for different solution.

Wrap

Darujme.sk and Salesforce.com are great tools for non-profits.
Getting them to talk to each other, opens up many great possibilities.
Be it for donor management and segmentation, more personalized & effective communication, centralized organization dashboards and more.

If you need help with integration of (not just) Salesforce.com and darujme.sk, don't hesitate to get in touch.

Happy hacking!

Continue reading

Salesforce.com REST API for data manipulation

August 15, 2024
Salesforce.com REST API for data manipulation

Need to manipulate Salesforce.com data quickly?
Would you rather use the tools and language you know?
We too, let's get it done.

Úsmev (Smile) app from Inuko

May 07, 2024
Úsmev (Smile) app from Inuko

In previous blogs, we introduced the applications we created for non-governmental organizations or non-profits. Among them, the most outstanding were our apps for the organization Cesta von, which helps marginalized communities throughout Slovakia to get out of the trap of poverty and to ensure a better life for future generations.

Slovak lesson with Amalka

March 05, 2024
Slovak lesson with Amalka

The third successful program of Cesta von is the language support program Amal, which mainly develops communication skills in the Slovak language among mothers and parents from the communities, also thanks to the connection of people from the majority…

©2022-2024 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net