A quick article on some code added to a button in ZohoBooks off the invoice module to initiate your Stripe terminal to take a payment.
Why?
Just to make it easy on the staff at a counter or on the phone. They bring up the invoie in ZohoBooks, click on the button, and the Stripe terminal will ask for the amount on the invoice.
Well almost. We've gone the extra step in that we added a custom field that can override the full balance, to allow partial payments such as a deposit or instalment.
How?
I won't go in to how to create a button in ZohoBooks but you simply add it to the invoice and then when it prompts for some code, you give it the snippet below.
The Magic
You would create a button for each terminal
copyraw
/* *******************************************************************************
Function: Map Take_Payment( Map invoice, Map organization, Map user)
Label: Take Payment
Trigger: On button click
Purpose: Preps stripe terminal to take payment for balance of invoice.
Inputs: invoice
Outputs: -
Date Created: 2023-02-24
- Initial release
- Reads Books Invoice and sends the amount to the reader for a payment atempt
Date Modified: 2023-02-24 (Joel Lipman)
- If custom field "Amount To Be Taken" is not greater than zero, then defaults to balance due of invoice.
More Information:
TEST PAYMENT SCENARIOS WITH PHYSICAL TEST CARD
// Send in as payment endings: eg. $100.00 == payment approved
00 Payment Approved
01 Payment Declined // call issuer code
05 Payment Declined Generic
55 Payment Declined Incorrect Pin
65 Payment Declined withdrawal_count_limit_exceeded
75 Pin try exceeded
******************************************************************************* */
v_BooksOrgID = organization.get("organization_id");
//
// some Stripe variables (add your own here)
v_StripeTerminalID = "tmr_ABCDEFGabcdefg";
v_StripeCustomerKey = "sk_live_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
V_StripeLocationsEndpoint = " https://api.stripe.com/v1/terminal/locations";
//
// set Stripe header
m_Headers = Map();
m_Headers.put("Authorization","Bearer " + v_StripeCustomerKey);
//
// default to balance due on this Invoice
v_AmountToPay = ifnull(invoice.get("balance"),0);
//
// Get custom Amount to Pay from Invoice
l_CustomFields = invoice.get("custom_fields");
if(l_CustomFields.size() > 0)
{
for each m_CustomField in l_CustomFields
{
if(m_CustomField.get("label") == "Amount To Be Taken")
{
if(m_CustomField.get("value") > 0)
{
v_AmountToPay = m_CustomField.get("value");
}
}
}
}
//
// format to Stripe amount
v_AmountToPay = v_AmountToPay.truncate(2);
v_StripeAmount = v_AmountToPay * 100;
v_StripeAmount = v_StripeAmount.floor();
v_StripeAmount = v_StripeAmount.toNumber();
info v_StripeAmount;
//
// Create payment intent in Stripe
v_PaymentIntentEndpoint = "https://api.stripe.com/v1/payment_intents";
m_Params = Map();
m_Params.put("amount",v_StripeAmount);
m_Params.put("currency","gbp");
//m_Params.put("automatic_payment_methods[enabled]", false);
m_Params.put("payment_method_types[]","card_present");
m_Params.put("capture_method","manual");
v_DescriptionString = "IN: " + invoice.get("invoice_number") + " ID: " + invoice.get("invoice_id");
m_Params.put("description",v_DescriptionString);
// Later Add Code to "customer_id": "123456700000001234567", get customer id then Email
v_BooksCustomerID = invoice.get("customer_id");
r_CustomerDetails = zoho.books.getRecordsByID("contacts",v_BooksOrgID,v_BooksCustomerID,"zbooks");
v_CustomerCheckCode = r_CustomerDetails.get("code");
if(v_CustomerCheckCode == 0)
{
m_ContactDetails = r_CustomerDetails.get("contact");
if(m_ContactDetails != null)
{
m_Params.put("receipt_email",m_ContactDetails.get("email"));
}
}
r_CreatePaymentIntent = invokeurl
[
url :v_PaymentIntentEndpoint
type :POST
parameters:m_Params
headers:m_Headers
];
info "Payment Intent Create";
info r_CreatePaymentIntent;
//
v_CheckObject = ifnull(r_CreatePaymentIntent.get("object"),"-");
v_CheckAmount = ifnull(r_CreatePaymentIntent.get("amount"),"-");
//
// Process Payment Intent
if(v_CheckObject == "payment_intent")
{
v_PaymentIntentID = r_CreatePaymentIntent.get("id");
info "Payment Intent Created Successfuly!!! ID: ";
info v_PaymentIntentID;
//
// Hand Off Payment Intent to Reader
v_ReaderHandOffEndpoint = "https://api.stripe.com/v1/terminal/readers/" + v_StripeTerminalID + "/process_payment_intent";
m_PaymentHandOffParams = Map();
m_PaymentHandOffParams.put("payment_intent",v_PaymentIntentID);
r_ReaderPaymentHandOff = invokeurl
[
url :v_ReaderHandOffEndpoint
type :POST
parameters:m_PaymentHandOffParams
headers:m_Headers
];
info r_ReaderPaymentHandOff;
}
return r_ReaderPaymentHandOff;
- /* *******************************************************************************
- Function: Map Take_Payment( Map invoice, Map organization, Map user)
- Label: Take Payment
- Trigger: On button click
- Purpose: Preps stripe terminal to take payment for balance of invoice.
- Inputs: invoice
- Outputs: -
- Date Created: 2023-02-24
- - Initial release
- - Reads Books Invoice and sends the amount to the reader for a payment atempt
- Date Modified: 2023-02-24 (Joel Lipman)
- - If custom field "Amount To Be Taken" is not greater than zero, then defaults to balance due of invoice.
- More Information:
- TEST PAYMENT SCENARIOS WITH PHYSICAL TEST CARD
- // Send in as payment endings: eg. $100.00 == payment approved
- 00 Payment Approved
- 01 Payment Declined // call issuer code
- 05 Payment Declined Generic
- 55 Payment Declined Incorrect Pin
- 65 Payment Declined withdrawal_count_limit_exceeded
- 75 Pin try exceeded
- ******************************************************************************* */
- v_BooksOrgID = organization.get("organization_id");
- //
- // some Stripe variables (add your own here)
- v_StripeTerminalID = "tmr_ABCDEFGabcdefg";
- v_StripeCustomerKey = "sk_live_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
- V_StripeLocationsEndpoint = " https://api.stripe.com/v1/terminal/locations";
- //
- // set Stripe header
- m_Headers = Map();
- m_Headers.put("Authorization","Bearer " + v_StripeCustomerKey);
- //
- // default to balance due on this Invoice
- v_AmountToPay = ifnull(invoice.get("balance"),0);
- //
- // Get custom Amount to Pay from Invoice
- l_CustomFields = invoice.get("custom_fields");
- if(l_CustomFields.size() > 0)
- {
- for each m_CustomField in l_CustomFields
- {
- if(m_CustomField.get("label") == "Amount To Be Taken")
- {
- if(m_CustomField.get("value") > 0)
- {
- v_AmountToPay = m_CustomField.get("value");
- }
- }
- }
- }
- //
- // format to Stripe amount
- v_AmountToPay = v_AmountToPay.truncate(2);
- v_StripeAmount = v_AmountToPay * 100;
- v_StripeAmount = v_StripeAmount.floor();
- v_StripeAmount = v_StripeAmount.toNumber();
- info v_StripeAmount;
- //
- // Create payment intent in Stripe
- v_PaymentIntentEndpoint = "https://api.stripe.com/v1/payment_intents";
- m_Params = Map();
- m_Params.put("amount",v_StripeAmount);
- m_Params.put("currency","gbp");
- //m_Params.put("automatic_payment_methods[enabled]", false);
- m_Params.put("payment_method_types[]","card_present");
- m_Params.put("capture_method","manual");
- v_DescriptionString = "IN: " + invoice.get("invoice_number") + " ID: " + invoice.get("invoice_id");
- m_Params.put("description",v_DescriptionString);
- // Later Add Code to "customer_id": "123456700000001234567", get customer id then Email
- v_BooksCustomerID = invoice.get("customer_id");
- r_CustomerDetails = zoho.books.getRecordsByID("contacts",v_BooksOrgID,v_BooksCustomerID,"zbooks");
- v_CustomerCheckCode = r_CustomerDetails.get("code");
- if(v_CustomerCheckCode == 0)
- {
- m_ContactDetails = r_CustomerDetails.get("contact");
- if(m_ContactDetails != null)
- {
- m_Params.put("receipt_email",m_ContactDetails.get("email"));
- }
- }
- r_CreatePaymentIntent = invokeUrl
- [
- url :v_PaymentIntentEndpoint
- type :POST
- parameters:m_Params
- headers:m_Headers
- ];
- info "Payment Intent Create";
- info r_CreatePaymentIntent;
- //
- v_CheckObject = ifnull(r_CreatePaymentIntent.get("object"),"-");
- v_CheckAmount = ifnull(r_CreatePaymentIntent.get("amount"),"-");
- //
- // Process Payment Intent
- if(v_CheckObject == "payment_intent")
- {
- v_PaymentIntentID = r_CreatePaymentIntent.get("id");
- info "Payment Intent Created Successfuly!!! ID: ";
- info v_PaymentIntentID;
- //
- // Hand Off Payment Intent to Reader
- v_ReaderHandOffEndpoint = "https://api.stripe.com/v1/terminal/readers/" + v_StripeTerminalID + "/process_payment_intent";
- m_PaymentHandOffParams = Map();
- m_PaymentHandOffParams.put("payment_intent",v_PaymentIntentID);
- r_ReaderPaymentHandOff = invokeUrl
- [
- url :v_ReaderHandOffEndpoint
- type :POST
- parameters:m_PaymentHandOffParams
- headers:m_Headers
- ];
- info r_ReaderPaymentHandOff;
- }
- return r_ReaderPaymentHandOff;
The incoming webhook
You now need to receive the Stripe webhook when it comes back into ZohoBooks to record it against the invoice. Note how we added the invoice reference and Zoho ID in the description of the payment capture in our previous bit of code:
copyraw
/* *******************************************************************************
Function: Map stripe_terminal_payment( Map invoice, Map organization, Map user)
Label: stripe_terminal_payment
Trigger: Incoming Webhook
Purpose: Listens for stripe terminal payments.
OAuth URL: https://www.zohoapis.com/books/v3/settings/incomingwebhooks/iw_stripe_terminal_payment/execute?auth_type=oauth
Inputs: invoice
Outputs: -
Date Created: 2023-03-23
- Initial release
- been successfully proccessed by the physical reader
Date Modified: 2024-05-21 (Joel Lipman)
- Revamp of code as per best practices
- Correct error: Check and update the code in line 74 as there is a Exception : Value is empty and 'get' function cannot be applied
More Information:
Navigate to payments, then find pending webhook response event, terminal.reader.action_succeeded, then view event details
******************************************************************************* */
//
// initialize
m_Blank = Map();
m_Response = Map();
v_BooksOrgID = organization.get("organization_id");
//
// Stripe API Key (add your own here)
v_StripeCustomerKey = "sk_live_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
//
// set Stripe header
m_Headers = Map();
m_Headers.put("Authorization","Bearer " + v_StripeCustomerKey);
//
// capture response webhook
m_Webhook = Map();
m_Webhook.put("data",body.get("data"));
m_Webhook.put("type",body.get("type"));
//
// Check Type
m_Data = ifnull(m_Webhook.get("data"),m_Blank);
v_Type = ifnull(m_Webhook.get("type"),"-");
if(v_Type == "terminal.reader.action_succeeded")
{
// Get Payment Intent that needs to be Captured
m_Object = m_Data.get("object");
m_Action = m_Object.get("action");
m_ProcessPaymentIntent = m_Action.get("process_payment_intent");
v_PaymentIntentID = m_ProcessPaymentIntent.get("payment_intent");
//
// Retrieve details on the Payment Intent
v_PaymentIntentEndpoint = "https://api.stripe.com/v1/payment_intents/" + v_PaymentIntentID;
r_PaymentIntentDetails = invokeurl
[
url :v_PaymentIntentEndpoint
type :GET
headers:m_Headers
];
//
// get amount (used to capture payment intent)
v_Amount = ifnull(r_PaymentIntentDetails.get("amount"),0);
v_AmountReceived = ifnull(r_PaymentIntentDetails.get("amount_received"),0);
v_AmountCapturable = ifnull(r_PaymentIntentDetails.get("amount_capturable"),0);
v_CaptureIntentEndpoint = "https://api.stripe.com/v1/payment_intents/" + v_PaymentIntentID + "/capture";
m_CaptureParams = Map();
m_CaptureParams.put("amount_to_capture",v_AmountCapturable);
r_CapturePayment = invokeurl
[
url :v_CaptureIntentEndpoint
type :POST
parameters:m_CaptureParams
headers:m_Headers
];
//
// get card details (we need to store last 4 digits)
v_StripeReference = "";
v_Last4Digits = "";
v_ZB_InvoiceID = 0;
m_Charges = ifnull(r_PaymentIntentDetails.get("charges"),m_Blank);
l_Data = ifnull(m_Charges.get("data"),{});
for each m_Data in l_Data
{
if(m_Data.get("id") != null)
{
v_StripeReference = m_Data.get("id");
}
if(m_Data.get("payment_method_details") != null)
{
m_CardPresent = ifnull(m_Data.get("payment_method_details").get("card_present"),m_Blank);
v_Last4Digits = ifnull(m_CardPresent.get("last4"),"");
}
if(m_Data.get("description") != null)
{
v_ChargeDescription = ifnull(m_Data.get("description"),"");
v_ZB_InvoiceID = v_ChargeDescription.getSuffix("ID: ");
v_ZB_InvoiceID = if(isNumber(v_ZB_InvoiceID),v_ZB_InvoiceID,0).toLong();
}
}
//
// create payment record
if(v_ZB_InvoiceID != 0)
{
//
// get ZohoBooks nominal account for Stripe
v_NominalAccountID = "";
r_ChartOfAccounts = invokeurl
[
url :"https://www.zohoapis.com/books/v3/chartofaccounts?organization_id=" + v_BooksOrgID
type :GET
connection:"zbooks"
];
if(r_ChartOfAccounts.get("chartofaccounts") != null)
{
for each m_NomAccount in r_ChartOfAccounts.get("chartofaccounts")
{
if(m_NomAccount.get("account_name").equalsIgnoreCase("Stripe Clearing"))
{
v_NominalAccountID = m_NomAccount.get("account_id");
}
}
}
//
// retrieve invoice details from ZohoBooks
r_InvoiceDetails = zoho.books.getRecordsByID("invoices",v_BooksOrgID,v_ZB_InvoiceID,"zbooks");
m_Invoice = ifnull(r_InvoiceDetails.get("invoice"),m_Blank);
if(m_Invoice.get("customer_id") != null)
{
m_CreatePayment = Map();
m_CreatePayment.put("customer_id",m_Invoice.get("customer_id"));
m_CreatePayment.put("payment_mode","In Person - Card");
m_CreatePayment.put("amount",v_AmountReceived / 100);
m_CreatePayment.put("date",zoho.currentdate.toString("yyyy-MM-dd"));
//
l_Invoices = List();
m_ThisInvoice = Map();
m_ThisInvoice.put("invoice_id",v_ZB_InvoiceID.toString());
m_ThisInvoice.put("amount_applied",v_AmountReceived / 100);
l_Invoices.add(m_ThisInvoice);
m_CreatePayment.put("invoices",l_Invoices);
m_CreatePayment.put("invoice_id",v_ZB_InvoiceID.toString());
m_CreatePayment.put("amount_applied",v_AmountReceived / 100);
//
v_PaymentRef = if(v_StripeReference=="", m_Invoice.get("invoice_number"), v_StripeReference);
m_CreatePayment.put("reference_number",v_PaymentRef);
m_CreatePayment.put("account_id",v_NominalAccountID);
//info m_CreatePayment;
//
r_CreatePayment = zoho.books.createRecord("customerpayments",v_BooksOrgID,m_CreatePayment,"zbooks");
//info r_CreatePayment;
if(r_CreatePayment.get("message") != null)
{
if(r_CreatePayment.get("message").contains("payment has been created"))
{
//
// update the invoice (request by client to store last 4 digits on invoice)
l_CustomFields = List();
m_CustomField = Map();
m_CustomField.put("api_name","cf_last_4_digits");
m_CustomField.put("value",v_Last4Digits);
l_CustomFields.add(m_CustomField);
m_UpdateInvoice = Map();
m_UpdateInvoice.put("custom_fields",l_CustomFields);
r_UpdateInvoice = zoho.books.updateRecord("Invoices",v_BooksOrgID,v_ZB_InvoiceID,m_UpdateInvoice,"zbooks");
}
}
}
}
}
m_Response.put("message",r_CreatePayment.get("message"));
m_Response.put("code",0);
return m_Response;
- /* *******************************************************************************
- Function: Map stripe_terminal_payment( Map invoice, Map organization, Map user)
- Label: stripe_terminal_payment
- Trigger: Incoming Webhook
- Purpose: Listens for stripe terminal payments.
- OAuth url: https://www.zohoapis.com/books/v3/settings/incomingwebhooks/iw_stripe_terminal_payment/execute?auth_type=oauth
- Inputs: invoice
- Outputs: -
- Date Created: 2023-03-23
- - Initial release
- - been successfully proccessed by the physical reader
- Date Modified: 2024-05-21 (Joel Lipman)
- - Revamp of code as per best practices
- - Correct error: Check and update the code in line 74 as there is a Exception : Value is empty and 'get' function cannot be applied
- More Information:
- Navigate to payments, then find pending webhook response event, terminal.reader.action_succeeded, then view event details
- ******************************************************************************* */
- //
- // initialize
- m_Blank = Map();
- m_Response = Map();
- v_BooksOrgID = organization.get("organization_id");
- //
- // Stripe API Key (add your own here)
- v_StripeCustomerKey = "sk_live_1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
- //
- // set Stripe header
- m_Headers = Map();
- m_Headers.put("Authorization","Bearer " + v_StripeCustomerKey);
- //
- // capture response webhook
- m_Webhook = Map();
- m_Webhook.put("data",body.get("data"));
- m_Webhook.put("type",body.get("type"));
- //
- // Check Type
- m_Data = ifnull(m_Webhook.get("data"),m_Blank);
- v_Type = ifnull(m_Webhook.get("type"),"-");
- if(v_Type == "terminal.reader.action_succeeded")
- {
- // Get Payment Intent that needs to be Captured
- m_Object = m_Data.get("object");
- m_Action = m_Object.get("action");
- m_ProcessPaymentIntent = m_Action.get("process_payment_intent");
- v_PaymentIntentID = m_ProcessPaymentIntent.get("payment_intent");
- //
- // Retrieve details on the Payment Intent
- v_PaymentIntentEndpoint = "https://api.stripe.com/v1/payment_intents/" + v_PaymentIntentID;
- r_PaymentIntentDetails = invokeUrl
- [
- url :v_PaymentIntentEndpoint
- type :GET
- headers:m_Headers
- ];
- //
- // get amount (used to capture payment intent)
- v_Amount = ifnull(r_PaymentIntentDetails.get("amount"),0);
- v_AmountReceived = ifnull(r_PaymentIntentDetails.get("amount_received"),0);
- v_AmountCapturable = ifnull(r_PaymentIntentDetails.get("amount_capturable"),0);
- v_CaptureIntentEndpoint = "https://api.stripe.com/v1/payment_intents/" + v_PaymentIntentID + "/capture";
- m_CaptureParams = Map();
- m_CaptureParams.put("amount_to_capture",v_AmountCapturable);
- r_CapturePayment = invokeUrl
- [
- url :v_CaptureIntentEndpoint
- type :POST
- parameters:m_CaptureParams
- headers:m_Headers
- ];
- //
- // get card details (we need to store last 4 digits)
- v_StripeReference = "";
- v_Last4Digits = "";
- v_ZB_InvoiceID = 0;
- m_Charges = ifnull(r_PaymentIntentDetails.get("charges"),m_Blank);
- l_Data = ifnull(m_Charges.get("data"),{});
- for each m_Data in l_Data
- {
- if(m_Data.get("id") != null)
- {
- v_StripeReference = m_Data.get("id");
- }
- if(m_Data.get("payment_method_details") != null)
- {
- m_CardPresent = ifnull(m_Data.get("payment_method_details").get("card_present"),m_Blank);
- v_Last4Digits = ifnull(m_CardPresent.get("last4"),"");
- }
- if(m_Data.get("description") != null)
- {
- v_ChargeDescription = ifnull(m_Data.get("description"),"");
- v_ZB_InvoiceID = v_ChargeDescription.getSuffix("ID: ");
- v_ZB_InvoiceID = if(isNumber(v_ZB_InvoiceID),v_ZB_InvoiceID,0).toLong();
- }
- }
- //
- // create payment record
- if(v_ZB_InvoiceID != 0)
- {
- //
- // get ZohoBooks nominal account for Stripe
- v_NominalAccountID = "";
- r_ChartOfAccounts = invokeUrl
- [
- url :"https://www.zohoapis.com/books/v3/chartofaccounts?organization_id=" + v_BooksOrgID
- type :GET
- connection:"zbooks"
- ];
- if(r_ChartOfAccounts.get("chartofaccounts") != null)
- {
- for each m_NomAccount in r_ChartOfAccounts.get("chartofaccounts")
- {
- if(m_NomAccount.get("account_name").equalsIgnoreCase("Stripe clearing"))
- {
- v_NominalAccountID = m_NomAccount.get("account_id");
- }
- }
- }
- //
- // retrieve invoice details from ZohoBooks
- r_InvoiceDetails = zoho.books.getRecordsByID("invoices",v_BooksOrgID,v_ZB_InvoiceID,"zbooks");
- m_Invoice = ifnull(r_InvoiceDetails.get("invoice"),m_Blank);
- if(m_Invoice.get("customer_id") != null)
- {
- m_CreatePayment = Map();
- m_CreatePayment.put("customer_id",m_Invoice.get("customer_id"));
- m_CreatePayment.put("payment_mode","In Person - Card");
- m_CreatePayment.put("amount",v_AmountReceived / 100);
- m_CreatePayment.put("date",zoho.currentdate.toString("yyyy-MM-dd"));
- //
- l_Invoices = List();
- m_ThisInvoice = Map();
- m_ThisInvoice.put("invoice_id",v_ZB_InvoiceID.toString());
- m_ThisInvoice.put("amount_applied",v_AmountReceived / 100);
- l_Invoices.add(m_ThisInvoice);
- m_CreatePayment.put("invoices",l_Invoices);
- m_CreatePayment.put("invoice_id",v_ZB_InvoiceID.toString());
- m_CreatePayment.put("amount_applied",v_AmountReceived / 100);
- //
- v_PaymentRef = if(v_StripeReference=="", m_Invoice.get("invoice_number"), v_StripeReference);
- m_CreatePayment.put("reference_number",v_PaymentRef);
- m_CreatePayment.put("account_id",v_NominalAccountID);
- //info m_CreatePayment;
- //
- r_CreatePayment = zoho.books.createRecord("customerpayments",v_BooksOrgID,m_CreatePayment,"zbooks");
- //info r_CreatePayment;
- if(r_CreatePayment.get("message") != null)
- {
- if(r_CreatePayment.get("message").contains("payment has been created"))
- {
- //
- // update the invoice (request by client to store last 4 digits on invoice)
- l_CustomFields = List();
- m_CustomField = Map();
- m_CustomField.put("api_name","cf_last_4_digits");
- m_CustomField.put("value",v_Last4Digits);
- l_CustomFields.add(m_CustomField);
- m_UpdateInvoice = Map();
- m_UpdateInvoice.put("custom_fields",l_CustomFields);
- r_UpdateInvoice = zoho.books.updateRecord("Invoices",v_BooksOrgID,v_ZB_InvoiceID,m_UpdateInvoice,"zbooks");
- }
- }
- }
- }
- }
- m_Response.put("message",r_CreatePayment.get("message"));
- m_Response.put("code",0);
- return m_Response;
Source(s):
- Stripe Dashboard - Developers - API Keys
- Stripe Docs - Decline codes - Learn about decline codes and how to resolve them when a charge fails.
Category: Zoho :: Article: 841



Add comment