Shopify Hydrogen App Development Tutorial 101 Part 3 - Partner App Backend Integration using Temporal.io (Shopping Cart and Payment - ep 2)
1. Introduction
In episode 1, we have created handlers to add and remove items from our shopping cart. Temporal allows us to represent the cart as a function invocation. Using signals to modify the cart state and query to fetch the cart state.
Speaking of queries, we will use this tool for our current step of the project: cart checkout.
2. Creating Cart Checkout
After the user is putting their product, the next thing they would need to do is to proceed to checkout so they can purchase their items. With the same method as in the previous part, the checkout process will need a signal and handler.
src/workflows.js
import * as wf from '@temporalio/workflow';
const { greet } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// ....
/** Check Out Signal */
export const CheckoutSignal = wf.defineSignal('Checkout');
/** Cart workflow */
export async function CartWorkflow() {
// ....
/** Checkout handler */
wf.setHandler(CheckoutSignal, async function checkoutSignal() {
// if email is undefined, return
if (CartState.value.email === undefined) {
CartState.value.status = 'ERROR';
CartState.value.error = 'Must have email to check out!';
return;
};
// if there is no items, also return error / nothing
if (CartState.value.length === 0) {
CartState.value.status = 'ERROR';
CartState.value.error = 'Must have items to check out!';
return;
};
// count all total price
let totalPrice = 0;
for (let i in CartState.value.items) {
console.log(i);
let selectedProduct = CartState.value.items[i];
let productPrice = selectedProduct.price * selectedProduct.quantity;
totalPrice += productPrice;
};
console.log('Total price: ', totalPrice);
// if it passes error checking, error will be undefined
CartState.value.error = undefined;
CartState.value.status = 'CHECKED_OUT';
console.log('changed cart status ', CartState.value.status);
});
}
/** Utility state function */
// ...
For this step, checkout handler will do a few checks before proceeding. It will check the value of email and the length of the cart. This shopping cart required an email (to send an abandoned cart email if the user doesn’t proceed with the transaction) and items on the cart. We can’t make any checkout if our cart is empty. Only after both of the condition is true, it will accumulate the total item price and the state of cart status will be changed to checked out.
3. Integrating with Payment Service Provider
What comes after checking out? Billing of course. We can integrate a payment provider with our temporal app. We will write this integration in the activities component then retrieve it in the workflow to call.
The provider that I will be using for this post is midtrans, an Indonesian payment gateway service. And I will limit the payment method to GoPay e-money method which is compatible with QRIS (QR Code Indonesian Standard).
3.1. Signing up for midtrans
To use midtrans e-money Core API, we need to sign up to obtain our server key. Because this is a demo app, use the sandbox environment key to proceed. To see the key, go to settings-access keys from the dashboard.
3.2. Writing our payment activity
Now that we have our access keys, we can fully use the e-money Core API and write it in activities.We write calls to API in activities instead of in the Workflow because Workflows should be pure idempotent functions to allow Temporal to re-run a Workflow to recreate the Workflow's state
src/activities.js
import { v4 as uuidv4 } from 'uuid';
import pkg from 'midtrans-client';
const { CoreApi } = pkg;
// ....
// create core API instance
export const createMidtransPayment = (transactionAmount) => {
let core = new CoreApi({
isProduction: false,
serverKey: 'YOUR-SERVER-KEY',
clientKey: 'YOUR-CLIENT-KEY'
});
let parameter = {
"payment_type": "gopay",
"transaction_details": {
"gross_amount": transactionAmount,
"order_id": "ecommerce-" + uuidv4(),
},
"gopay": {
"enable_callback": true,
"callback_url": "",
}
}
const promise = core.charge(parameter);
const paymentPromise = promise.then((chargeReponse) => chargeReponse);
return paymentPromise;
}
The above code will receive a transactionAmount parameter that will be filled on what we calculated in the checkout handler and then will charge customer for that same amount.
Notes:
- Order ID must be unique for every transaction, if there are two transactions with the same id, the later would catch an error and transaction creation would be cancelled. That’s why we are using an uuid package to create unique id for every transaction.
3.3 Calling Activity from workflow
Now we can schedule the create payment Activity from the workflow.
src/workflows.js
import * as wf from '@temporalio/workflow';
const { createMidtransPayment } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// ....
export async function CartWorkflow() {
// ....
}
/** Utility state function */
// ...
The response from the create payment Activity API call will need to be saved as a state that can be accessed even outside of the workflow, which is the created transaction ID and e-money QR Code.
Define transaction ID and QR Code state.
src/workflows.js
import * as wf from '@temporalio/workflow';
const { createMidtransPayment } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// ....
/** Cart workflow */
export async function CartWorkflow() {
// ....
const TransactionId = useState("TransactionId", "Transaction Id Placeholder");
const QRCode = useState("QRCode", "QR Code Placeholder");
/** Checkout handler */
// ....
}
/** Utility state function */
// ...
The state we created receives two arguments, the first one being the name and the second argument is the initial value.
Call the payment Activity
src/workflows.js
import * as wf from '@temporalio/workflow';
const { createMidtransPayment} = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// ....
/** Cart workflow */
export async function CartWorkflow() {
// ....
/** Checkout handler */
wf.setHandler(CheckoutSignal, async function checkoutSignal() {
// if email is undefined, return
if (CartState.value.email === undefined) {
CartState.value.status = 'ERROR';
CartState.value.error = 'Must have email to check out!';
return;
};
// if there is no items, also return error / nothing
if (CartState.value.length === 0) {
CartState.value.status = 'ERROR';
CartState.value.error = 'Must have items to check out!';
return;
};
// count all total price
let totalPrice = 0;
for (let i in CartState.value.items) {
console.log(i);
let selectedProduct = CartState.value.items[i];
let productPrice = selectedProduct.price * selectedProduct.quantity;
totalPrice += productPrice;
};
console.log('Total price: ', totalPrice);
/** MIDTRANS TRANSACTION */
// create transaction
const result = await createMidtransPayment(totalPrice).catch(err => console.log(err));
console.log(`Please scan QR code below to fulfill transaction \n${result.actions[0].url}`); // send user the qr code
// save transaction id
TransactionId.value = result.transaction_id;
console.log('set transaction id value ', await TransactionId.value)
// // save qr code
QRCode.value = result.actions[0].url;
console.log('set qr code value ', await QRCode.value);
// set transaction status to -> created
CartState.value.transactionStatus = 'CREATED';
console.log('changed transaction status ', await CartState.value.transactionStatus);
// if it passes error checking, error will be undefined
CartState.value.error = undefined;
CartState.value.status = 'CHECKED_OUT';
console.log('changed cart status ', CartState.value.status);
});
}
/** Utility state function */
// ...
What we’re doing is saving the result from the call API as a constant, extract transaction ID and QR Code from the object and use it to update the value of the state. The cart would need a status whenever a certain process has completed. As it just created a transaction, then the transaction status would be ‘CREATED’.
4. Sending an Abandoned Cart Email
Abandoned cart emails are emails sent to re-engage shoppers who left their items in their carts without completing checkout. I’m sure everyone have had received it at some point, and usually looks similar to this
We can easily implement sending emails similar to above using Temporal. For easy demonstration, it’s better to use a free forever email delivery service. In the original post, OP used Mailgun to deliver emails to the customer. Because Mailgun doesn’t have a free tier, I looked up other viable alternatives and found Mailjet, you can send emails with a limit of 200 emails per day for free.
4.1. Signing up for Mailjet
Sign up for a free account at Mailjet and do a simple setup to register our sender address for the email at Account Settings-Senders & Domains-Add a Sender Domain or Address-Add a sender address. Mailjet will send us an activation email, click on the activation link to continue.
We can make use of their template builder to create the email. There are many types to choose from, from marketing, transaction, and automation.
We can just drag and drop to build our email, like creating a poster with Canva.
Save and publish so we can immediately use the API, Mailjet will redirect you to your published template page. Take notes of your template ID because we’re going to use it to make calls.
4.2. Writing send email Activity
The following is a code to connect with node-mailjet. Node-mailjet will also need server and client keys like when we were connecting to midtrans API. To generate your keys, go to Account Settings-REST API-API Key Management (Primary and Sub-account).
This function receives two arguments: customerEmail and customerName. Those arguments will act as the recipient info.
src/activities.js
// ....
// send abandoned cart email
export const sendAbandonedCartEmail = (customerEmail, customerName) => {
const request = mailjet.connect('PUBLIC-KEY', 'PRIVATE-KEY') // connect to API
.post("send", {'version': 'v3.1'})
.request({
"Messages":[
{
"From": {
"Email": "youremail@mail.com",
"Name": "your name"
},
"To": [
{
"Email": customerEmail,
"Name": customerName
}
],
"TemplateID": YOURTEMPLATEID,
"TemplateLanguage": true,
"Subject": `Forgot Something, ${customerName}?`,
"Variables": {}
}
]
})
const promise = request.then((result) => result);
console.log('response: ', promise.response);
return promise;
};
4.3. Sending emails
Before scheduling send email Activity, here’s the process would be like:
- Cart will wait for 24 hour
- If the payment is not fulfilled until the end
- Workflow will send the abandoned cart email to customer
After knowing the process, we should schedule the create payment Activity in the workflow.
import * as wf from '@temporalio/workflow';
const { createMidtransPayment, sendAbandonedCartEmail } = wf.proxyActivities({
startToCloseTimeout: '1 minute',
});
// ....
export async function CartWorkflow() {
// ....
}
/** Utility state function */
// ...
For the handler logic, every cart state should have an email (so we can send them reminder and other emails). After 24 hours without settled payment, status of the cart should changed from in progress to abandoned. And when a cart is abandoned by the user, we’ll send them an email that they haven’t finished their transaction
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// ....
/** function handler for abandoned cart email */
async function handleAbandonedCartEmail() {
// if there is no email defined, return
if (CartState.value.email === undefined) {
return;
};
// set cart status to abandoned
CartState.value.status = 'ABANDONED';
const sendEmail = await sendAbandonedCartEmail(CartState.value.email, CartState.value.name); // call function from activities
console.log('abandoned cart email sended.');
console.log('see sendEmail response ', sendEmail);
}
}
/** Utility state function */
// ...
Because we already have the send email handler, we can finally write the planned workflow process.
src/workflows.js
// ....
/** Cart workflow */
export async function CartWorkflow() {
// ....
const TransactionId = useState("TransactionId", "Transaction Id Placeholder");
const QRCode = useState("QRCode", "QR Code Placeholder");
const IsPaid = useState("IsPaid", false);
// ....
// if user already checks out and paid the transaction, workflow would be finished.
if (
await wf.condition(
() => CartState.value.status === 'CHECKED_OUT' && IsPaid.value === true,
'4 minutes' // wait for 24 hour (for teseting purposes change to 4 mins)
)
) {
console.log('checked out and paid. ')
console.log('see cart items:', await CartState.value)
} else {
// because it's already 24 hour, set transaction status to expired
CartState.value.transactionStatus = 'EXPIRED';
console.log('timeout');
console.log('transaction status: ', await CartState.value.transactionStatus);
console.log('cart abandoned.');
await handleAbandonedCartEmail();
}
console.log('workflow done.');
}
/** Utility state function */
// ...
5. Next up
We have finished most of the process, but we’re quite missing some logic, like an event listener to listen whenever a midtrans transaction status is updated. And triggers to make our cart working. For the next part, we will write all of that and also make the signal triggers via REST API. Stay tuned!
Post a Comment