← 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 };