Protected customer data (Level 1): order ids are stored as keyed HMACs, order numbers and ids for admin links as AES-256-GCM ciphertext (DATA_ENCRYPTION_KEY). New public /terms page with processor terms; privacy policy links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
3.1 KiB
TypeScript
75 lines
3.1 KiB
TypeScript
// orders/create -> analytics (F23, N02). The function's candidate message is the offer name, or "A + B" when
|
|
// one line carries two offers; we attribute by name. If Shopify reports only the discount title
|
|
// (DISCOUNT_TITLE, "Offerlyst bundles"), the usage is kept under that title. Verify the exact title on a dev store (README).
|
|
import db from "../db.server";
|
|
import { encrypt, hashId } from "./crypto.server";
|
|
|
|
export interface OrderPayload {
|
|
id: number | string;
|
|
name?: string;
|
|
currency?: string;
|
|
total_price?: string;
|
|
discount_applications?: { type?: string; title?: string; description?: string | null }[];
|
|
line_items?: {
|
|
price?: string;
|
|
quantity?: number;
|
|
discount_allocations?: { amount?: string; discount_application_index?: number }[];
|
|
}[];
|
|
}
|
|
|
|
export async function recordOrder(
|
|
shopId: string,
|
|
order: OrderPayload,
|
|
offers: { id: string; name: string }[],
|
|
discountTitle: string,
|
|
) {
|
|
const rawOrderId = String(order.id);
|
|
const orderId = hashId(rawOrderId); // stored hashed: protected customer data, encrypted at rest
|
|
const apps = order.discount_applications || [];
|
|
const byName = new Map(offers.map((o) => [o.name, o.id]));
|
|
const ours = new Map<number, string[]>(); // application index -> offer names
|
|
apps.forEach((a, i) => {
|
|
const title = (a.title || a.description || "").trim();
|
|
if (!title) return;
|
|
const parts = title.split(" + ").map((s) => s.trim());
|
|
if (parts.every((p) => byName.has(p))) ours.set(i, parts);
|
|
else if (title === discountTitle && a.type !== "discount_code") ours.set(i, [discountTitle]);
|
|
});
|
|
|
|
const perOffer = new Map<string, { discount: number; revenue: number }>();
|
|
for (const li of order.line_items || []) {
|
|
const lineTotal = Number(li.price || 0) * (li.quantity || 0);
|
|
for (const al of li.discount_allocations || []) {
|
|
const names = ours.get(al.discount_application_index ?? -1);
|
|
if (!names) continue;
|
|
const amount = Number(al.amount || 0);
|
|
// a merged line ("A + B"): split evenly, we don't know the per-offer share
|
|
for (const n of names) {
|
|
const e = perOffer.get(n) || { discount: 0, revenue: 0 };
|
|
e.discount += amount / names.length;
|
|
e.revenue += lineTotal / names.length;
|
|
perOffer.set(n, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasBundle = perOffer.size > 0;
|
|
const currency = order.currency || "USD";
|
|
const total = Number(order.total_price || 0);
|
|
const existing = await db.orderStat.findUnique({ where: { shopId_orderId: { shopId, orderId } } });
|
|
if (!existing) {
|
|
await db.orderStat.create({ data: { shopId, orderId, total, hasBundle, currency } });
|
|
}
|
|
for (const [name, v] of perOffer) {
|
|
await db.orderUsage.upsert({
|
|
where: { shopId_orderId_offerName: { shopId, orderId, offerName: name } },
|
|
create: {
|
|
shopId, orderId, orderRef: encrypt(rawOrderId), orderName: encrypt(order.name || null), offerId: byName.get(name) || null, offerName: name,
|
|
discount: Math.round(v.discount * 100) / 100, bundleRevenue: Math.round(v.revenue * 100) / 100, orderTotal: total, currency,
|
|
},
|
|
update: {},
|
|
});
|
|
}
|
|
return { hasBundle, isNew: !existing };
|
|
}
|