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.
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.
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.
InuTransaction
and if the donor does not yet exists in our database into a Contact
and Account
.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.
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);
}
}
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;
}
The code below is conceptually simple. It fetches donations from darujme.sk and creates corresponding objects in salesforce.com.
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.
We look for donations made by companies, and we will create corresponding accounts if they don't already exists.
We look for contacts using the donor's email, and we will create contacts if the email is not already in salesforce.
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;
}
The only thing left is to actually periodically call the code above. We we will setup in salesforce 'Scheduled Jobs' (cron).
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.
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!