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.