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
Xof this order” - The transaction has
gateway: "gift_card"and areceiptJsonblob
- 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
- Manually-issued cards get treated as liability reductions, reducing a liability that was never booked.
- Discounted cards redeem at full face value, blowing a hole in the liability account (we booked €40, customer redeems €50).
The Three Accounting Scenarios
| Scenario | Purchase booking | Redemption booking |
|---|---|---|
| Manually-issued card | No purchase — nothing booked | Treat redemption as a plain discount line item on the sales invoice. No payment document. |
| Purchased card, no discount | Full face value → liability | gift_card_payment document reduces liability by redeemed amount |
| Purchased card, discounted at purchase | Face value + gift_card_discount line → net paid amount to liability | Redemption 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
FromgetShopifyManualGiftCardGids 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. NativeMap 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:- Sales invoice with the normal product lines plus one or more
gift_card_discountlines. Eachgift_card_discountline 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%. - One
gift_card_paymentdocument 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).
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
| Layer | File | Role |
|---|---|---|
| Core | packages/core/src/shopify/get-gift-cards-by-ids.ts | GraphQL lookup, classify manual vs purchased, compute discount ratio |
| Activity | packages/temporal-workflows/src/activities/shopify/get-gift-card-origins.ts | Temporal wrapper, Map→Record serialization |
| Activity | packages/temporal-workflows/src/activities/shopify/get-shopify-orders-enriched.ts | Extracts GIDs from receiptJson, calls origins activity |
| Workflow helper | packages/temporal-workflows/src/workflows/sync/gift-card-enrichment.ts | Rehydrates Set + Map onto adapter resources |
| Workflow | packages/temporal-workflows/src/workflows/sync/api-mode-sync.ts | Calls the enrichment helper between fetch and transform |
| Adapter helpers | packages/temporal-workflows/src/adapters/shopify/orders/helpers.ts | extractGiftCardGidFromReceipt, splitDiscountByTaxRate, gift-card line item + payment builders |
| Adapter | packages/temporal-workflows/src/adapters/shopify/orders/index.ts | Orchestrates line items, payments, and reconciliation |
| Utils | packages/utils/src/shopify-order-transformers.ts | processLineItemDiscountAllocations 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 === truelines first. - Don’t try to match gift cards by code, balance, or amount — use the GID
from
receiptJson. - Don’t pass
MaporSetthrough 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).