Skip to main content

Gift Card Discounts

This page explains how CONA retrieves and accounts for discounts applied to Shopify gift cards — both at purchase time and at redemption time. The redemption flow is non-obvious because Shopify does not tell us which gift card a customer used on their current order — we have to walk back through transaction receipts to the original purchase order to recover the discount. This doc exists so future devs don’t have to re-derive it from scratch.

Why This Is Tricky

At redemption, the Shopify order only tells us:
  • “A gift card paid X of this order”
  • The transaction has gateway: "gift_card" and a receiptJson blob
It does not directly tell us:
  • Whether that gift card was bought (liability on our books) or manually issued by the merchant (no liability)
  • Whether the gift card was discounted at purchase (e.g. €50 card bought for €40) — which matters because the liability we booked is only €40, not €50
Without this, two kinds of accounting bugs appear:
  1. Manually-issued cards get treated as liability reductions, reducing a liability that was never booked.
  2. Discounted cards redeem at full face value, blowing a hole in the liability account (we booked €40, customer redeems €50).
The flow below solves both.

The Three Accounting Scenarios

ScenarioPurchase bookingRedemption booking
Manually-issued cardNo purchase — nothing bookedTreat redemption as a plain discount line item on the sales invoice. No payment document.
Purchased card, no discountFull face value → liabilitygift_card_payment document reduces liability by redeemed amount
Purchased card, discounted at purchaseFace value + gift_card_discount line → net paid amount to liabilityRedemption applies both a proportional gift_card_discount line on the sales invoice and a reduced gift_card_payment for the net amount. Discount is split across tax rates proportionally.

High-Level Flow on Redemption

Key Insight: The receiptJson → GiftCard GID Bridge

Shopify’s gift card transaction carries a receiptJson field that — for gateway "gift_card" — includes the specific gift_card_id used. That ID is what we lift via extractGiftCardGidFromReceipt and use as the deterministic link back to the original GiftCard node. From the GiftCard node we can navigate to GiftCard.order, which is the purchase order (or null for manual cards). This is the only reliable way; do not try to match on code, balance, or customer — those are all ambiguous.

GraphQL Query Shape

From getShopifyManualGiftCardGids in @cona/core: Discount ratio is computed only from lines where isGiftCard === true, so unrelated discounts on other products in the same purchase order do not pollute the ratio.

Activity → Workflow → Adapter Handoff (Map Serialization)

Temporal JSON-serializes all activity payloads. Native Map and Set objects do not survive that round-trip, so we serialize them at the activity boundary and rehydrate inside the workflow before handing them to the adapter. The enrichResourcesWithGiftCardData helper lives in packages/temporal-workflows/src/workflows/sync/gift-card-enrichment.ts and is deliberately extracted as a pure function so it is unit-testable without the Temporal runtime.

Redemption-Time Document Shape

For an order paid with a purchased, discounted gift card, the adapter emits:
  1. Sales invoice with the normal product lines plus one or more gift_card_discount lines. Each gift_card_discount line is split across the order’s tax rates proportionally to the pre-discount gross value at each rate. Example: a €6 discount on an order with €20 @ 19% VAT and €10 @ 7% VAT becomes two lines: €4 @ 19% and €2 @ 7%.
  2. One gift_card_payment document per gift card transaction, with the payment amount reduced by the proportional discount (so the payment equals the net liability we booked at purchase, not the gross face value).
Manually-issued cards are different — they produce a plain discount line item on the sales invoice and no payment document (because there is no liability to reduce).

Rounding Consistency

getPurchasedGiftCardDiscountTotal and getPurchasedGiftCardDiscountLineItems must agree to the cent. Both round per transaction (roundMoney(amount × discountRatio)) before summing, because summing unrounded floats and rounding once at the end would drift against the sum of per-line rounded amounts and trigger spurious “orderTotal ≠ sum of line items” mismatch warnings.

Files Involved

LayerFileRole
Corepackages/core/src/shopify/get-gift-cards-by-ids.tsGraphQL lookup, classify manual vs purchased, compute discount ratio
Activitypackages/temporal-workflows/src/activities/shopify/get-gift-card-origins.tsTemporal wrapper, Map→Record serialization
Activitypackages/temporal-workflows/src/activities/shopify/get-shopify-orders-enriched.tsExtracts GIDs from receiptJson, calls origins activity
Workflow helperpackages/temporal-workflows/src/workflows/sync/gift-card-enrichment.tsRehydrates Set + Map onto adapter resources
Workflowpackages/temporal-workflows/src/workflows/sync/api-mode-sync.tsCalls the enrichment helper between fetch and transform
Adapter helperspackages/temporal-workflows/src/adapters/shopify/orders/helpers.tsextractGiftCardGidFromReceipt, splitDiscountByTaxRate, gift-card line item + payment builders
Adapterpackages/temporal-workflows/src/adapters/shopify/orders/index.tsOrchestrates line items, payments, and reconciliation
Utilspackages/utils/src/shopify-order-transformers.tsprocessLineItemDiscountAllocations tags purchase-time discounts as gift_card_discount on isGiftCard lines

Common Pitfalls for Future Devs

  • Don’t compute the discount ratio from the whole purchase order total. Other items on the same order can have their own discounts; you must filter to isGiftCard === true lines first.
  • Don’t try to match gift cards by code, balance, or amount — use the GID from receiptJson.
  • Don’t pass Map or Set through a Temporal activity return value — they will silently become {} after JSON round-trip. Always serialize at the boundary and rehydrate in the workflow.
  • Don’t round once at the end when summing per-line discounts — round per transaction to stay consistent with line item rounding.
  • Don’t treat manual and purchased cards the same at redemption — they hit different accounts (one is a discount/revenue reduction, the other is a liability reduction).