Trust is earned, not given

A different perspective

2023-11-14 · Projects

Shopify in C#, part 5: payments and refunds — transactions as the ledger, calculate-then-commit

Part 5from the Shopify in C# series · 6 parts in all

A Stripe developer asks: "where is the payment intent?" The Shopify answer: there is none, deliberately. Shopify Payments abstracts the PSP away — the API-facing truth is the transaction: an append-only ledger entry on each order (authorization, sale, capture, refund, void). Part 5 reads that ledger in GraphQL, then builds the refund flow the way Shopify wants it done: calculate first, commit second.

Reading the money trail: transactions in GraphQL

// kinds you will meet:
//   SALE          captured charge (Shopify Payments card sale)
//   AUTHORIZATION held funds, awaiting capture
//   CAPTURE       the capture of an earlier AUTHORIZATION
//   REFUND        money returned (one per refund, sums with the order's
//                 displayFinancialStatus -> PARTIALLY_REFUNDED)
//   VOID          authorization cancelled before capture
//   EMV_AUTH      terminal-originated auth (in-person)
const string ledger = @"
query orderMoney($id: ID!) {
  order(id: $id) {
    name
    displayFinancialStatus
    transactions(first: 20) {
      edges { node {
        kind status
        amount { amount currencyCode }
        gateway               # ""shopify_payments"" / ""cash"" / ""gift_card""
        parentTransaction { id kind }
        test                  # true while you use test mode
      } }
    }
  }
}";
// status: SUCCESS / PENDING / FAILURE. parentTransaction links a CAPTURE to
// its AUTHORIZATION and a REFUND to its SALE - that chain IS the intent.

Refunds, the Shopify way: calculate → commit

// REST shape, because the two-step contract is clearest there:
// 1. CALCULATE (dry run - returns the full refund plan, charges nothing):
//    POST /admin/api/2023-10/orders/{order_id}/refunds.json?calculate=true
//    { "refund": { "shipping": { "full_refund": true },
//                  "refund_line_items": [ { "line_item_id": 123456,
//                                           "quantity": 1,
//                                           "restock": true } ] } }
//    Response: the computed shipping refund, duties, and per-line amounts.
// 2. COMMIT (the real thing - same payload, plus the transactions you want):
//    POST /admin/api/2023-10/orders/{order_id}/refunds.json
//    { "refund": { ...same line items...,
//                  "transactions": [ { "parent_id": 987654,   // the SALE
//                                      "amount": 45.99,
//                                      "kind": "refund",
//                                      "gateway": "shopify_payments" } ] } }
// Skipping calculate and committing blind is how shops refund $45.99 against
// a $4.59 line item. The dry run is the API contract that keeps you honest.

The same flow in GraphQL (refundCreate)

// GraphQL mirrors the two-step: order.refundableLineItems tells you what
// CAN be refunded, refundCreate commits with structured quantities.
const string refundable = @"
query refundables($id: ID!) {
  order(id: $id) {
    name
    refundableLineItems(first: 20) {
      edges { node { lineItem { title } quantity refundableQuantity } }
    }
  }
}";

const string refundCreate = @"
mutation refundCreate($input: RefundInput!) {
  refundCreate(input: $input) {
    refund { id totalRefundedSet { shopMoney { amount } } }
    userErrors { field message }
  }
}";
var data = await PostGraphAsync(http, refundCreate, new {
    input = new Dictionary<string, object?> {
        ["orderId"] = gid,
        ["note"] = "RMA 5501 - wrong size",
        ["shipping"] = new { fullRefund = true },
        ["refundLineItems"] = new object[] {
            new Dictionary<string, object?> {
                ["lineItemId"] = lineItemGid, ["quantity"] = 1,
                ["restockType"] = "RETURN"   // RETURN / CANCEL / NO_RESTOCK
            }},
        // omit "transactions" to let Shopify refund the original payment:
        // the common case - it synthesizes the REFUND transaction itself.
    }});

What you deliberately do not get

No card numbers, no PSP handles, no webhook-per-attempt replay of Stripe's payment_intent.* family. Shopify Payments events arrive as order/transaction webhooks (orders/update, refunds/create, transactions/create), and gateway-level truth (disputes, chargebacks) is a dashboard-plus-webhook story, not an API you script. The division of labor in MapleCart: Stripe for custom-built payment flows, Shopify for everything checkout-and-fulfillment. The last piece of that machine is the fulfillment side — next part, in the GraphQL-first 2025 world.