← back to Dw Signup Fulfillment
lib/sample-ledger.js
73 lines
'use strict';
const crypto = require('crypto');
const NS = 'custom';
const USED_KEY = 'free_samples_used';
const COUNTED_KEY = 'free_samples_counted';
function verifyShopifyHmac(rawBody, receivedHmac, secret) {
if (!Buffer.isBuffer(rawBody) || !receivedHmac || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const left = Buffer.from(expected);
const right = Buffer.from(String(receivedHmac));
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
function isApprovedTradeTags(tags) {
const list = Array.isArray(tags) ? tags : String(tags || '').split(',');
return list.map((tag) => tag.trim()).includes('trade_approved');
}
function countDiscountedSamples(order) {
if (!order?.customer?.id) return 0;
return (order.line_items || []).reduce((total, item) => {
const sample = /\bsample\b/i.test(item.variant_title || '') || /-sample$/i.test(item.sku || '');
const unitPrice = Number(item.price || 0);
if (!sample || unitPrice <= 0) return total;
const allocated = (item.discount_allocations || []).reduce(
(sum, allocation) => sum + Number(allocation.amount || 0),
0
);
const units = Math.floor((allocated + 0.00001) / unitPrice);
return total + Math.min(Number(item.quantity || 0), Math.max(0, units));
}, 0);
}
class SampleUsageLedger {
constructor(graphql) { this.graphql = graphql; }
async process(order) {
const discounted = countDiscountedSamples(order);
if (!discounted) return { status: 'ignored', increment: 0 };
const orderGid = `gid://shopify/Order/${order.id}`;
const customerGid = `gid://shopify/Customer/${order.customer.id}`;
const state = await this.graphql(
`query SampleUsageState($order: ID!, $customer: ID!) {
order: node(id: $order) { ... on Order { counted: metafield(namespace: "${NS}", key: "${COUNTED_KEY}") { value compareDigest } } }
customer: node(id: $customer) { ... on Customer { tags used: metafield(namespace: "${NS}", key: "${USED_KEY}") { value compareDigest } } }
}`,
{ order: orderGid, customer: customerGid }
);
if (state.order?.counted?.value === 'true') return { status: 'duplicate', increment: 0 };
if (isApprovedTradeTags(state.customer?.tags)) return { status: 'approved_trade', increment: 0 };
const used = Math.max(0, Number(state.customer?.used?.value || 0));
const next = Math.min(3, used + discounted);
const result = await this.graphql(
`mutation RecordSampleUsage($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) { userErrors { field message code } }
}`,
{ metafields: [
{ ownerId: customerGid, namespace: NS, key: USED_KEY, type: 'number_integer', value: String(next), compareDigest: state.customer?.used?.compareDigest ?? null },
{ ownerId: orderGid, namespace: NS, key: COUNTED_KEY, type: 'boolean', value: 'true', compareDigest: state.order?.counted?.compareDigest ?? null },
] }
);
const errors = result.metafieldsSet?.userErrors || [];
if (errors.length) throw new Error(errors.map((error) => error.message).join('; '));
return { status: 'counted', increment: next - used, used: next };
}
}
module.exports = { SampleUsageLedger, countDiscountedSamples, isApprovedTradeTags, verifyShopifyHmac };