feat(shopify): port the order sync from the transactions feed to webshop_orders (#1676)

* feat(shopify): port the order sync from the transactions feed to webshop_orders

Shopify orders now land as rich rows on the Orders page (platform
'shopify'), the same surface WooCommerce uses, instead of opaque
bank-feed rows on the 1584 cash account:

- order-sync.ts writes through the shared upsertWebshopOrders service;
  the 1584/ensureManualCashAccount wiring is gone (prod has zero Shopify
  feed rows). Cursor/overlap/dedup, revoked classification and the
  frozen external_id formats are unchanged.
- vat_breakdown is reconstructed from the order-level taxLines
  (net = tax/rate, remainder as a 0%-bucket, refuse on unusable data);
  refund VAT is prorated from the parent order's mix. The line-item
  snapshot is stored only when it reconstructs the charged total to the
  ore, else the invoice conversion falls back to one aggregate line.
- GraphQL query gains createdAt, taxesIncluded, taxLines, lineItems and
  shippingLines (all non-PII; page size 100 -> 25 for query cost).
- Nav gate counts active shopify_connections; the Orders empty-state CTA
  goes to the platform-neutral /import hub; panel/manifest copy now
  points at the Orders page (sv + en).
- Paid-only qualification and the 90-day backfill stay; the
  bookkeeping-lock row filter is dropped (lock is enforced at booking,
  parity with WooCommerce).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(shopify): carry the prorated parent tax on refunds when per-rate bucketing is refused

A refund whose parent vat_breakdown was refused (unreported rates) stored
total_tax 0 and prefilled a 0%-refund with no moms reversal. The parent's
total tax is now prorated into the refund row, so the booking dialog's
ratio-inference fallback presents an editable bucket with the reversal
instead (CodeRabbit + Swedish review + skeptic finding). Adds the
mixed-rate line and truncated shipping-page tests CodeRabbit asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-18 10:57:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cfdddb2d7e
commit bc357531cc
17 changed files with 888 additions and 493 deletions
@@ -269,9 +269,11 @@ describe('shopify extension routes', () => {
vi.mocked(syncShopifyOrders).mockResolvedValue({
fetched: 3,
refundsFetched: 1,
imported: 4,
duplicates: 0,
skippedLocked: 0,
inserted: 4,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
})
const res = await findRoute('POST', '/sync').handler(
@@ -280,7 +282,7 @@ describe('shopify extension routes', () => {
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.transactions.imported).toBe(4)
expect(body.transactions.inserted).toBe(4)
expect(vi.mocked(syncShopifyOrders).mock.calls[0][0]).toEqual({ service: true })
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const listOrdersPage = vi.fn()
@@ -11,42 +11,48 @@ vi.mock('../lib/api-client', () => ({
error instanceof Error && error.message === 'REVOKED',
}))
vi.mock('@/lib/transactions/ingest', () => ({
ingestTransactions: vi.fn(),
vi.mock('@/lib/webshop-orders/ingest', () => ({
upsertWebshopOrders: vi.fn(),
}))
vi.mock('@/lib/cash-accounts/service', () => ({
ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'),
}))
vi.mock('@/lib/import/account-sync', () => ({
syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }),
}))
import { ingestTransactions } from '@/lib/transactions/ingest'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { encryptCredential } from '../lib/credentials'
import {
SHOPIFY_IMPORT_SOURCE,
SHOPIFY_LEDGER_ACCOUNT,
mapOrder,
mapRefund,
buildRefundVatBreakdown,
buildVatBreakdown,
mapLineItems,
mapOrderToWebshopRow,
mapRefundToWebshopRow,
orderAmountUnparseable,
orderQualifies,
rowBehindLock,
shopifyOrderExternalId,
shopifyRefundExternalId,
shopifyShopScope,
syncShopifyOrders,
} from '../lib/order-sync'
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
import type {
ShopifyConnection,
ShopifyLineItem,
ShopifyOrder,
ShopifyRefund,
ShopifyShippingLine,
ShopifyTaxLine,
} from '../types'
beforeAll(() => {
vi.stubEnv('SHOPIFY_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
})
// Set before the describe bodies run: makeConnection() encrypts credentials
// at collection time (same pattern as the WooCommerce order-sync test).
process.env.SHOPIFY_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
afterAll(() => {
vi.unstubAllEnvs()
})
const emptyUpsertResult = {
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
function makeConnection(overrides: Partial<ShopifyConnection> = {}): ShopifyConnection {
return {
@@ -74,41 +80,77 @@ function money(amount: string, currencyCode = 'SEK') {
return { shopMoney: { amount, currencyCode } }
}
function taxLine(ratePercentage: number | null, amount: string): ShopifyTaxLine {
return { ratePercentage, priceSet: money(amount) }
}
function lineItem(
name: string,
quantity: number,
total: string,
taxLines: ShopifyTaxLine[] = [],
): ShopifyLineItem {
return { name, quantity, discountedTotalSet: money(total), taxLines }
}
function shippingLine(
title: string | null,
price: string,
taxLines: ShopifyTaxLine[] = [],
): ShopifyShippingLine {
return { title, discountedPriceSet: money(price), taxLines }
}
function conn<T>(nodes: T[], hasNextPage = false) {
return { pageInfo: { hasNextPage }, nodes }
}
/**
* Default order: tax-inclusive Swedish store, 1250 kr gross at 25% VAT
* (1000 net + 250 moms), one product line covering the whole total.
*/
function makeOrder(overrides: Partial<ShopifyOrder> = {}): ShopifyOrder {
return {
legacyResourceId: '1042',
name: '#1042',
test: false,
createdAt: '2026-08-01T09:00:00Z',
processedAt: '2026-08-01T09:04:30Z',
updatedAt: '2026-08-01T09:05:00Z',
displayFinancialStatus: 'PAID',
paymentGatewayNames: ['Klarna'],
taxesIncluded: true,
totalPriceSet: money('1250.00'),
taxLines: [taxLine(25, '250.00')],
lineItems: conn([lineItem('Produkt A', 2, '1250.00', [taxLine(25, '250.00')])]),
shippingLines: conn<ShopifyShippingLine>([]),
refunds: [],
...overrides,
}
}
function makeRefund(overrides: Partial<ShopifyRefund> = {}): ShopifyRefund {
return {
legacyResourceId: '77',
createdAt: '2026-08-03T10:00:00Z',
totalRefundedSet: money('250.00'),
...overrides,
}
}
/** One-page result helper; the loop terminates on hasNextPage: false. */
function page(orders: ShopifyOrder[], hasNextPage = false, endCursor: string | null = null) {
return { orders, hasNextPage, endCursor }
}
/** Minimal chainable supabase mock covering the sync's query patterns. */
function makeSupabaseMock(options: { lockThrough?: string | null } = {}) {
function makeSupabaseMock() {
const updates: Array<{ table: string; values: Record<string, unknown> }> = []
const client = {
from(table: string) {
const builder = {
select: () => builder,
eq: () => builder,
maybeSingle: async () => ({
data:
table === 'company_settings'
? { bookkeeping_locked_through: options.lockThrough ?? null }
: null,
error: null,
}),
update: (values: Record<string, unknown>) => {
updates.push({ table, values })
return builder
@@ -126,6 +168,10 @@ function cursorUpdates(updates: Array<{ table: string; values: Record<string, un
)
}
function upsertedRows(call = 0): WebshopOrderUpsert[] {
return vi.mocked(upsertWebshopOrders).mock.calls[call][3] as WebshopOrderUpsert[]
}
beforeEach(() => {
vi.clearAllMocks()
createShopifySession.mockResolvedValue({
@@ -133,11 +179,7 @@ beforeEach(() => {
accessToken: 'token-1',
})
listOrdersPage.mockResolvedValue(page([]))
vi.mocked(ingestTransactions).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
} as Awaited<ReturnType<typeof ingestTransactions>>)
vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult })
})
describe('frozen external_id formats', () => {
@@ -160,9 +202,8 @@ describe('frozen external_id formats', () => {
expect(shopifyShopScope('minbutik.myshopify.com')).toBe('minbutik.myshopify.com')
})
it('import source and ledger account are frozen', () => {
it('retired feed import source is frozen', () => {
expect(SHOPIFY_IMPORT_SOURCE).toBe('shopify')
expect(SHOPIFY_LEDGER_ACCOUNT).toBe('1584')
})
})
@@ -179,118 +220,327 @@ describe('orderQualifies', () => {
})
})
describe('mapOrder', () => {
it('maps a paid order to one gross row dated by processedAt', () => {
const rows = mapOrder('minbutik.myshopify.com', makeOrder())
describe('buildVatBreakdown', () => {
it('derives per-rate nets from the order-level tax lines', () => {
expect(buildVatBreakdown(makeOrder())).toEqual([{ rate: 25, net: 1000, tax: 250 }])
})
it('handles mixed rates, highest first', () => {
const order = makeOrder({
totalPriceSet: money('1362.00'),
taxLines: [taxLine(12, '12.00'), taxLine(25, '250.00')],
})
// 25%: net 1000 + 250; 12%: net 100 + 12 = 1362 total, no remainder.
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 1000, tax: 250 },
{ rate: 12, net: 100, tax: 12 },
])
})
it('books the uncovered remainder as a 0%-bucket (zero-rated goods)', () => {
const order = makeOrder({
totalPriceSet: money('1750.00'),
taxLines: [taxLine(25, '250.00')],
})
expect(buildVatBreakdown(order)).toEqual([
{ rate: 25, net: 1000, tax: 250 },
{ rate: 0, net: 500, tax: 0 },
])
})
it('maps an entirely untaxed order to one 0%-bucket', () => {
const order = makeOrder({ totalPriceSet: money('900.00'), taxLines: [] })
expect(buildVatBreakdown(order)).toEqual([{ rate: 0, net: 900, tax: 0 }])
})
it('leaves öre-level drift to the booking residual instead of a fake 0%-sale', () => {
const order = makeOrder({
totalPriceSet: money('1250.30'),
taxLines: [taxLine(25, '250.00')],
})
expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 1000, tax: 250 }])
})
it('refuses a breakdown when a charged tax has no reported rate', () => {
const order = makeOrder({ taxLines: [taxLine(null, '250.00')] })
expect(buildVatBreakdown(order)).toEqual([])
})
it('refuses a breakdown whose buckets exceed the charged total', () => {
const order = makeOrder({
totalPriceSet: money('500.00'),
taxLines: [taxLine(25, '250.00')],
})
expect(buildVatBreakdown(order)).toEqual([])
})
it('returns [] for zero or unparseable totals', () => {
expect(buildVatBreakdown(makeOrder({ totalPriceSet: money('0.00') }))).toEqual([])
expect(buildVatBreakdown(makeOrder({ totalPriceSet: money('nope') }))).toEqual([])
})
})
describe('buildRefundVatBreakdown', () => {
it("prorates the parent order's mix by refund/order ratio", () => {
const { breakdown, totalTax } = buildRefundVatBreakdown(makeOrder(), makeRefund())
// 250 / 1250 = 20% of {net 1000, tax 250}.
expect(breakdown).toEqual([{ rate: 25, net: 200, tax: 50 }])
expect(totalTax).toBe(50)
})
it('still prorates the parent TOTAL tax when per-rate bucketing is refused', () => {
// Unreported rate: buildVatBreakdown refuses, but the parent was taxed.
// The refund row must still carry the moms reversal (total_tax), which
// the booking dialog's ratio-inference fallback turns into an editable
// bucket instead of a silent 0%-refund.
const order = makeOrder({ taxLines: [taxLine(null, '250.00')] })
expect(buildRefundVatBreakdown(order, makeRefund())).toEqual({
breakdown: [],
totalTax: 50,
})
})
it('returns zero tax when the parent genuinely carried none', () => {
const order = makeOrder({ totalPriceSet: money('900.00'), taxLines: [] })
// Parent maps to one 0%-bucket, prorating it yields a 0-tax bucket set.
const { totalTax } = buildRefundVatBreakdown(order, makeRefund())
expect(totalTax).toBe(0)
})
it('returns an empty breakdown for zero-amount refunds', () => {
expect(
buildRefundVatBreakdown(makeOrder(), makeRefund({ totalRefundedSet: money('0') })),
).toEqual({ breakdown: [], totalTax: 0 })
})
})
describe('mapLineItems', () => {
it('decomposes tax-inclusive lines into net + tax with the line rate', () => {
expect(mapLineItems(makeOrder())).toEqual([
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
])
})
it('keeps tax-exclusive line totals as the net', () => {
const order = makeOrder({
taxesIncluded: false,
lineItems: conn([lineItem('Produkt A', 2, '1000.00', [taxLine(25, '250.00')])]),
})
expect(mapLineItems(order)).toEqual([
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
])
})
it('includes shipping as its own line and marks untaxed lines 0%', () => {
const order = makeOrder({
totalPriceSet: money('1329.00'),
taxLines: [taxLine(25, '265.80')],
lineItems: conn([lineItem('Produkt A', 2, '1250.00', [taxLine(25, '250.00')])]),
shippingLines: conn([shippingLine(null, '79.00', [taxLine(25, '15.80')])]),
})
expect(mapLineItems(order)).toEqual([
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
{ name: 'Frakt', quantity: 1, total: 63.2, total_tax: 15.8, vat_rate: 25 },
])
})
it('drops the snapshot when the parts do not reconstruct the charged total', () => {
// Cart-level discount: 100 kr off the total that discountedTotalSet does
// not carry. An invoice built from these lines would overbill.
const order = makeOrder({ totalPriceSet: money('1150.00') })
expect(mapLineItems(order)).toEqual([])
})
it('drops the snapshot when the line-item page is truncated', () => {
const order = makeOrder({
lineItems: conn([lineItem('Produkt A', 2, '1250.00', [taxLine(25, '250.00')])], true),
})
expect(mapLineItems(order)).toEqual([])
})
it('drops the snapshot when the shipping-line page is truncated', () => {
const order = makeOrder({
shippingLines: conn<ShopifyShippingLine>([], true),
})
expect(mapLineItems(order)).toEqual([])
})
it('stores vat_rate null for a part taxed at two different rates', () => {
const order = makeOrder({
totalPriceSet: money('1370.00'),
taxLines: [taxLine(25, '250.00'), taxLine(12, '12.00')],
lineItems: conn([
lineItem('Paket', 1, '1370.00', [taxLine(25, '250.00'), taxLine(12, '12.00')]),
]),
})
expect(mapLineItems(order)).toEqual([
{ name: 'Paket', quantity: 1, total: 1108, total_tax: 262, vat_rate: null },
])
})
})
describe('mapOrderToWebshopRow', () => {
const connection = makeConnection()
it('maps a paid order to a full webshop_orders upsert row', () => {
const rows = mapOrderToWebshopRow(connection, 'minbutik.myshopify.com', makeOrder())
expect(rows).toEqual([
{
date: '2026-08-01',
description: 'Shopify-order #1042',
amount: 1250,
currency: 'SEK',
platform: 'shopify',
store_scope: 'minbutik.myshopify.com',
store_label: 'Testbutiken',
connection_id: 'conn-1',
row_type: 'order',
parent_external_id: null,
external_id: 'shopify_minbutik.myshopify.com_order_1042',
import_source: 'shopify',
reference: 'Klarna',
platform_order_id: '1042',
order_number: '#1042',
status: 'paid',
is_paid: true,
order_date: '2026-08-01',
paid_date: '2026-08-01',
currency: 'SEK',
total: 1250,
total_tax: 250,
vat_breakdown: [{ rate: 25, net: 1000, tax: 250 }],
line_items: [
{ name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 },
],
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: null,
payment_method: 'Klarna',
payment_method_title: 'Klarna',
gateway_reference: null,
refunded_total: 0,
},
])
})
it('rounds string money to two decimals and uppercases the currency', () => {
const rows = mapOrder('s', makeOrder({ totalPriceSet: money('99.995', 'eur') }))
expect(rows[0].amount).toBe(100)
expect(rows[0].currency).toBe('EUR')
it('uppercases the currency and reports the summed refund total', () => {
const order = makeOrder({
totalPriceSet: money('1250.00', 'eur'),
displayFinancialStatus: 'PARTIALLY_REFUNDED',
refunds: [makeRefund(), makeRefund({ legacyResourceId: '78' })],
})
const [row] = mapOrderToWebshopRow(connection, 's', order)
expect(row.currency).toBe('EUR')
expect(row.status).toBe('partially_refunded')
expect(row.refunded_total).toBe(500)
})
it('joins multiple gateways into the title and keys on the first', () => {
const order = makeOrder({ paymentGatewayNames: ['Shopify Payments', 'gift_card'] })
const [row] = mapOrderToWebshopRow(connection, 's', order)
expect(row.payment_method).toBe('Shopify Payments')
expect(row.payment_method_title).toBe('Shopify Payments, gift_card')
})
it('leaves the payment method null when no gateways are reported', () => {
const [row] = mapOrderToWebshopRow(connection, 's', makeOrder({ paymentGatewayNames: [] }))
expect(row.payment_method).toBeNull()
expect(row.payment_method_title).toBeNull()
})
it('skips unpaid, test, zero-total and unparseable orders', () => {
expect(mapOrder('s', makeOrder({ displayFinancialStatus: 'PENDING' }))).toEqual([])
expect(mapOrder('s', makeOrder({ test: true }))).toEqual([])
expect(mapOrder('s', makeOrder({ totalPriceSet: money('0.00') }))).toEqual([])
expect(mapOrder('s', makeOrder({ totalPriceSet: money('not-a-number') }))).toEqual([])
})
it('leaves the reference null when no gateways are reported', () => {
expect(mapOrder('s', makeOrder({ paymentGatewayNames: [] }))[0].reference).toBeNull()
expect(
mapOrderToWebshopRow(connection, 's', makeOrder({ displayFinancialStatus: 'PENDING' })),
).toEqual([])
expect(mapOrderToWebshopRow(connection, 's', makeOrder({ test: true }))).toEqual([])
expect(
mapOrderToWebshopRow(connection, 's', makeOrder({ totalPriceSet: money('0.00') })),
).toEqual([])
expect(
mapOrderToWebshopRow(connection, 's', makeOrder({ totalPriceSet: money('nope') })),
).toEqual([])
expect(orderAmountUnparseable(makeOrder({ totalPriceSet: money('nope') }))).toBe(true)
})
})
describe('mapRefund', () => {
const refund: ShopifyRefund = {
legacyResourceId: '77',
createdAt: '2026-08-03T10:00:00Z',
totalRefundedSet: money('250.00'),
}
describe('mapRefundToWebshopRow', () => {
const connection = makeConnection()
it('maps a refund to one negative row dated by the refund date', () => {
const rows = mapRefund('minbutik.myshopify.com', makeOrder(), refund)
it('maps a refund to a negative row parented to its order', () => {
const rows = mapRefundToWebshopRow(
connection,
'minbutik.myshopify.com',
makeOrder(),
makeRefund(),
)
expect(rows).toEqual([
{
date: '2026-08-03',
description: 'Shopify-återbetalning order #1042',
amount: -250,
currency: 'SEK',
platform: 'shopify',
store_scope: 'minbutik.myshopify.com',
store_label: 'Testbutiken',
connection_id: 'conn-1',
row_type: 'refund',
parent_external_id: 'shopify_minbutik.myshopify.com_order_1042',
external_id: 'shopify_minbutik.myshopify.com_refund_77',
import_source: 'shopify',
reference: null,
platform_order_id: '77',
order_number: '#1042',
status: 'refund',
is_paid: true,
order_date: '2026-08-03',
paid_date: '2026-08-03',
currency: 'SEK',
total: -250,
total_tax: -50,
vat_breakdown: [{ rate: 25, net: 200, tax: 50 }],
line_items: [],
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: null,
payment_method: 'Klarna',
payment_method_title: 'Klarna',
gateway_reference: null,
refunded_total: 0,
},
])
})
it('skips zero-amount refunds', () => {
expect(
mapRefund('s', makeOrder(), { ...refund, totalRefundedSet: money('0') }),
mapRefundToWebshopRow(connection, 's', makeOrder(), makeRefund({ totalRefundedSet: money('0') })),
).toEqual([])
})
})
describe('rowBehindLock', () => {
it('drops dates on/before the lock and keeps later ones', () => {
expect(rowBehindLock('2026-06-30', '2026-06-30')).toBe(true)
expect(rowBehindLock('2026-06-15', '2026-06-30')).toBe(true)
expect(rowBehindLock('2026-07-01', '2026-06-30')).toBe(false)
expect(rowBehindLock('2026-06-15', null)).toBe(false)
})
})
describe('syncShopifyOrders', () => {
it('ingests order and refund rows against the 1584 cash account and advances the cursor', async () => {
it('upserts order and refund rows and advances the cursor', async () => {
const { client, updates } = makeSupabaseMock()
const order = makeOrder({
displayFinancialStatus: 'PARTIALLY_REFUNDED',
refunds: [
{
legacyResourceId: '77',
createdAt: '2026-08-03T10:00:00Z',
totalRefundedSet: money('250.00'),
},
],
refunds: [makeRefund()],
})
listOrdersPage.mockResolvedValueOnce(page([order]))
vi.mocked(ingestTransactions).mockResolvedValueOnce({
imported: 2,
duplicates: 0,
errors: 0,
} as Awaited<ReturnType<typeof ingestTransactions>>)
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
inserted: 2,
})
const summary = await syncShopifyOrders(client, makeConnection())
expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, imported: 2, duplicates: 0 })
expect(ensureManualCashAccount).toHaveBeenCalledWith(
client,
'company-1',
'1584',
'SEK',
'Shopify-saldo',
)
expect(ingestTransactions).toHaveBeenCalledTimes(1)
const [, companyId, userId, rows, ingestOptions] =
vi.mocked(ingestTransactions).mock.calls[0]
expect(summary).toMatchObject({
fetched: 1,
refundsFetched: 1,
inserted: 2,
updated: 0,
errors: 0,
})
expect(upsertWebshopOrders).toHaveBeenCalledTimes(1)
const [, companyId, userId] = vi.mocked(upsertWebshopOrders).mock.calls[0]
expect(companyId).toBe('company-1')
expect(userId).toBe('user-1')
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
const rows = upsertedRows()
expect(rows.map((r) => r.external_id)).toEqual([
'shopify_minbutik.myshopify.com_order_1042',
'shopify_minbutik.myshopify.com_refund_77',
])
expect(ingestOptions).toEqual({ settlementAccount: '1584', skipAutoCategorization: true })
expect(rows[1].parent_external_id).toBe('shopify_minbutik.myshopify.com_order_1042')
// Cursor persisted from the page's max updatedAt, and any stale
// error_message is cleared on progress. A fully-listed window then
@@ -337,34 +587,7 @@ describe('syncShopifyOrders', () => {
expect(listOrdersPage.mock.calls[0][1].updatedAtMin).toBe('2026-08-04T12:00:00.000Z')
})
it('drops rows dated on/before the bookkeeping lock on every run', async () => {
const { client, updates } = makeSupabaseMock({ lockThrough: '2026-08-02' })
// Order paid 2026-08-01 (behind lock), refund created 2026-08-03 (after).
const order = makeOrder({
displayFinancialStatus: 'PARTIALLY_REFUNDED',
refunds: [
{
legacyResourceId: '77',
createdAt: '2026-08-03T10:00:00Z',
totalRefundedSet: money('250.00'),
},
],
})
listOrdersPage.mockResolvedValueOnce(page([order]))
const summary = await syncShopifyOrders(client, makeConnection())
expect(summary.skippedLocked).toBe(1)
const [, , , rows] = vi.mocked(ingestTransactions).mock.calls[0]
expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([
'shopify_minbutik.myshopify.com_refund_77',
])
// The cursor still advances (page + watermark): the drop is by design,
// not a failure.
expect(cursorUpdates(updates)).toHaveLength(2)
})
it('holds the cursor below a page whose ingest reported errors', async () => {
it('holds the cursor below a page whose upsert reported errors', async () => {
const { client, updates } = makeSupabaseMock()
// Two orders so the assertion distinguishes "first updatedAt minus 1s"
// (the floor rule) from "max updatedAt minus 1s".
@@ -374,11 +597,11 @@ describe('syncShopifyOrders', () => {
makeOrder({ legacyResourceId: '1043', name: '#1043', updatedAt: '2026-08-02T08:00:00Z' }),
]),
)
vi.mocked(ingestTransactions).mockResolvedValueOnce({
imported: 0,
duplicates: 0,
vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({
...emptyUpsertResult,
inserted: 1,
errors: 1,
} as Awaited<ReturnType<typeof ingestTransactions>>)
})
const summary = await syncShopifyOrders(client, makeConnection())
@@ -390,39 +613,6 @@ describe('syncShopifyOrders', () => {
expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z')
})
it('falls back to the first order currency when the shop currency was unreadable', async () => {
const { client } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce(
page([makeOrder({ totalPriceSet: money('10.00', 'eur') })]),
)
await syncShopifyOrders(client, makeConnection({ currency: null }))
expect(ensureManualCashAccount).toHaveBeenCalledWith(
client,
'company-1',
'1584',
'EUR',
'Shopify-saldo',
)
})
it('surfaces a cash-account failure on the connection instead of failing silently', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce(page([makeOrder()]))
vi.mocked(ensureManualCashAccount).mockRejectedValueOnce(
new Error('cash account 1584 exists with currency EUR'),
)
await expect(syncShopifyOrders(client, makeConnection())).rejects.toThrow(
/currency EUR/,
)
const errorUpdate = updates.find(
(u) => u.table === 'shopify_connections' && 'error_message' in u.values,
)
expect(errorUpdate?.values.error_message).toMatch(/1584/)
})
it('counts an unparseable order total as an error without stalling the cursor', async () => {
const { client, updates } = makeSupabaseMock()
listOrdersPage.mockResolvedValueOnce(
@@ -432,12 +622,26 @@ describe('syncShopifyOrders', () => {
const summary = await syncShopifyOrders(client, makeConnection())
expect(summary.errors).toBe(1)
expect(ingestTransactions).not.toHaveBeenCalled()
expect(upsertWebshopOrders).not.toHaveBeenCalled()
// Deliberate: a permanently corrupt total must not stall the feed
// (page cursor + end-of-run watermark both persist).
expect(cursorUpdates(updates)).toHaveLength(2)
})
it('counts an unparseable refund amount without dropping the order row', async () => {
const { client } = makeSupabaseMock()
const order = makeOrder({
refunds: [makeRefund({ totalRefundedSet: money('nope') })],
})
listOrdersPage.mockResolvedValueOnce(page([order]))
const summary = await syncShopifyOrders(client, makeConnection())
expect(summary.errors).toBe(1)
expect(summary.refundsFetched).toBe(1)
expect(upsertedRows().map((r) => r.row_type)).toEqual(['order'])
})
it('advances a watermark on an empty first run so quiet stores rotate in the cron', async () => {
const { client, updates } = makeSupabaseMock()
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { serverErrorMessage, syncSummary, type ShopifySyncPayload } from '../lib/settings-actions'
describe('syncSummary', () => {
const base = { fetched: 5, refundsFetched: 1, imported: 4, duplicates: 1, errors: 0 }
const base = { fetched: 5, refundsFetched: 1, inserted: 4, updated: 1, unchanged: 0, errors: 0 }
it('classifies a revoked run before anything else', () => {
expect(syncSummary({ transactions: { ...base, revoked: true } })).toEqual({
@@ -18,7 +18,7 @@ describe('syncSummary', () => {
it('classifies an empty window as its own outcome', () => {
expect(
syncSummary({ transactions: { ...base, fetched: 0, imported: 0 } }),
syncSummary({ transactions: { ...base, fetched: 0, inserted: 0 } }),
).toEqual({ reason: 'empty' })
})
+5 -5
View File
@@ -6,11 +6,11 @@ import { shopifyApiRoutes } from './api-routes'
*
* Connects a company's Shopify store via a merchant-created Dev Dashboard
* custom app (client credentials grant; the revealable shpat_ token flow was
* discontinued 2026-01-01) and imports the store's paid orders and refunds
* into the transactions inbox as a bank-style feed on the 1584 cash account.
* Feed-only (same doctrine as the Stripe and WooCommerce feeds): nothing is
* auto-booked, and Shopify Payments payout/fee reconciliation is out of
* scope for now (phase 2).
* discontinued 2026-01-01) and upserts the store's paid orders and refunds
* into webshop_orders (the Orders page), with per-rate VAT and a line-item
* snapshot as booking underlag. Feed-only (same doctrine as the WooCommerce
* sync): nothing is auto-booked, and Shopify Payments payout/fee
* reconciliation is out of scope for now (phase 2).
*
* Required environment variables:
* - SHOPIFY_CREDENTIALS_ENCRYPTION_KEY (at-rest key for client id/secret)
+36 -5
View File
@@ -17,8 +17,17 @@ import type { ShopifyOrder, ShopifyShopInfo } from '../types'
/** Pinned Admin API version; bump quarterly (supported >= 12 months). */
export const SHOPIFY_API_VERSION = '2026-07'
/** Orders per page; the API caps `first` at 250, 100 keeps query cost low. */
export const SHOPIFY_PAGE_SIZE = 100
/**
* Orders per page. The API caps `first` at 250, but query cost is what binds
* here: each order carries nested lineItems/shippingLines connections, and a
* single GraphQL query must stay under the 1000-point ceiling
* (25 * (~1 + lineItems 25 + shipping 5 + overhead) lands well below it).
*/
export const SHOPIFY_PAGE_SIZE = 25
/** Line items fetched per order; more than this drops the line snapshot. */
export const SHOPIFY_LINE_ITEMS_PAGE = 25
/** Shipping lines fetched per order; >5 on one order is effectively unheard of. */
export const SHOPIFY_SHIPPING_LINES_PAGE = 5
const REQUEST_TIMEOUT_MS = 30_000
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
@@ -257,9 +266,11 @@ export async function shopifyGraphQL<T>(
/**
* Order fields for the feed. Deliberately NO customer/PII fields (customer,
* email, addresses): they are gated behind Shopify's protected customer data
* program, and a bookkeeping feed does not need them; the verifikat reference
* is the order name/id. Refunds come inline (plain list, not a connection),
* so no per-order follow-up requests are needed.
* program, and the order feed does not need them; the verifikat reference is
* the order name/id. Tax lines, line items and shipping lines carry the
* booking underlag (per-rate VAT, line snapshot) for the Orders page.
* Refunds come inline (plain list, not a connection), so no per-order
* follow-up requests are needed.
*/
const ORDERS_QUERY = `
query OrdersFeed($first: Int!, $after: String, $query: String) {
@@ -269,11 +280,31 @@ query OrdersFeed($first: Int!, $after: String, $query: String) {
legacyResourceId
name
test
createdAt
processedAt
updatedAt
displayFinancialStatus
paymentGatewayNames
taxesIncluded
totalPriceSet { shopMoney { amount currencyCode } }
taxLines { ratePercentage priceSet { shopMoney { amount currencyCode } } }
lineItems(first: ${SHOPIFY_LINE_ITEMS_PAGE}) {
pageInfo { hasNextPage }
nodes {
name
quantity
discountedTotalSet { shopMoney { amount currencyCode } }
taxLines { ratePercentage priceSet { shopMoney { amount currencyCode } } }
}
}
shippingLines(first: ${SHOPIFY_SHIPPING_LINES_PAGE}) {
pageInfo { hasNextPage }
nodes {
title
discountedPriceSet { shopMoney { amount currencyCode } }
taxLines { ratePercentage priceSet { shopMoney { amount currencyCode } } }
}
}
refunds {
legacyResourceId
createdAt
+346 -241
View File
@@ -1,39 +1,51 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { syncMappedAccounts } from '@/lib/import/account-sync'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { createLogger, type Logger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { RawTransaction } from '@/types'
import { roundOre as round } from '@/lib/money'
import type { WebshopOrderLineItem, WebshopVatBreakdownLine } from '@/types'
import {
createShopifySession,
isRevokedCredentialsError,
listOrdersPage,
} from './api-client'
import { credentialsOf } from './credentials'
import type { ShopifyConnection, ShopifyOrder, ShopifyRefund } from '../types'
import type {
ShopifyConnection,
ShopifyOrder,
ShopifyRefund,
ShopifyTaxLine,
} from '../types'
const defaultLog = createLogger('shopify/order-sync')
/**
* Shopify order sync: the store's paid orders and refunds treated as a
* bank-style feed.
* Shopify order sync: the store's paid orders and refunds as rich rows in
* public.webshop_orders (the Orders page), replacing the earlier
* transactions-inbox feed (which shipped but was never enabled for real
* stores: prod holds zero Shopify feed rows, so no legacy cross-marking is
* ever expected here).
*
* The store becomes a cash account on ledger 1584 (Fordringar Shopify
* Payments in the 158x sub-account convention: money the payment gateways owe
* the merchant), and orders land in the transactions inbox exactly like PSD2
* bank rows: deduped on external_id, bound to the cash account so booking
* settles against 1584, and categorized/booked by the user through the normal
* flows. Nothing here auto-books (feed-only doctrine, same as the Stripe and
* WooCommerce feeds). 1680 and 1686 are owned by those feeds, and
* cash_accounts enforces one account per ledger per company.
* Row model: only PAID orders import (PAID / PARTIALLY_REFUNDED / REFUNDED;
* a deliberate difference from the WooCommerce sync, which also imports
* unpaid orders for the invoice flow). AUTHORIZED/PENDING orders re-surface
* via updatedAt once payment captures. Each refund of a paid order is a
* separate negative row parented to its order. Rows carry the booking
* underlag the Admin API exposes without touching Shopify's protected
* customer data program: per-rate VAT, a line-item snapshot, gateway names.
* Customer fields stay null (deliberate v1 decision): booking works, and
* "Skapa faktura" starts without a prefilled customer. Nothing here books
* anything (feed-only doctrine, same as the WooCommerce and Stripe feeds).
*
* Row model: a paid order produces one positive row for its gross total; each
* refund produces one negative row. Payment-processor fees never appear in
* this feed: order-level fee data only exists for Shopify Payments and payout
* reconciliation is a separate concern (phase 2); external gateways (Klarna,
* Stripe) report no fees through Shopify at all. The gateway names ride along
* as the row reference for later gateway-side reconciliation.
* The write path is upsertWebshopOrders() (lib/webshop-orders/ingest), which
* owns FX enrichment, the frozen-row rules for booked orders and the
* cross-mark against the retired transactions feed. Overlap re-polls are
* real upserts now (growing refund totals, status flips), not dedup no-ops.
*
* Rows behind company_settings.bookkeeping_locked_through import too (the
* page is an order overview, not just a booking queue; unlike the retired
* inbox feed, an unbookable webshop_orders row is not permanent noise). The
* booking route and the period-lock triggers refuse to BOOK them.
*
* Pagination: one fixed updated_at window per run, walked with Relay cursors
* (sortKey UPDATED_AT ascending). Cursors are stable across same-second ties,
@@ -42,31 +54,18 @@ const defaultLog = createLogger('shopify/order-sync')
* updatedAt processed, and after a fully-listed window the run's start time
* (a scanned-through watermark, so quiet and empty-first-run stores still
* rotate to the back of the cron's oldest-first selection). Re-polled with a
* 24h overlap; (company_id, external_id) dedup makes overlaps no-ops. It
* never advances past failed work: a page with ingest errors caps the
* 24h overlap; upsert-on-(company_id, external_id) makes overlaps idempotent.
* It never advances past failed work: a page with upsert errors caps the
* persisted cursor just below the page's first updatedAt, so the next run
* re-lists exactly the orders whose rows are incomplete. First run fetches
* BACKFILL_DAYS back.
*
* Lock-date guard: the window selects on updatedAt, but rows are dated by
* processedAt / refund createdAt, which can be arbitrarily older (a refund
* bumps updatedAt long after payment). Rows dated on or before
* company_settings.bookkeeping_locked_through are therefore dropped at map
* time on EVERY run: the enforce_company_lock_date trigger makes them
* permanently unbookable, and feed rows are undeletable by design, so
* importing them would create permanent inbox noise. Dropped rows are counted
* in skippedLocked and logged.
*/
/** BAS ledger account for the Shopify store cash account. */
export const SHOPIFY_LEDGER_ACCOUNT = '1584'
/** 158x sub-account name (e-handel convention); used for the chart account. */
const SHOPIFY_LEDGER_ACCOUNT_NAME = 'Fordringar Shopify Payments'
/** transactions.import_source for Shopify feed rows. */
/** transactions.import_source the retired feed used; kept for reference. */
export const SHOPIFY_IMPORT_SOURCE = 'shopify'
/** First-run backfill window (matches the WooCommerce/Enable Banking convention). */
export const BACKFILL_DAYS = 90
/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */
/** Cursor re-poll overlap; upsert-on-external_id makes overlaps idempotent. */
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
/**
* Safety cap on orders per run (matches the Stripe/WooCommerce feeds). The
@@ -77,12 +76,12 @@ const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
const MAX_ORDERS_PER_RUN = 10_000
/**
* ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and
* dedup compares stored ids byte-for-byte, exactly like the Stripe, Enable
* Banking and WooCommerce schemes. Changing a template silently orphans every
* prior row and re-imports the whole feed on the next sync. Locked by the
* frozen-format test in order-sync.test.ts; any change MUST ship a
* coordinated backfill.
* ⚠️ STORED-KEY FORMATS. These are persisted to webshop_orders.external_id
* (and historically to transactions.external_id by the retired feed; the
* cross-mark join depends on the schemes staying byte-identical). Changing a
* template silently orphans every prior row and re-imports the whole feed on
* the next sync. Locked by the frozen-format test in order-sync.test.ts; any
* change MUST ship a coordinated backfill.
*
* The scope is the store's normalized myshopify.com domain, NOT the
* connection id, so a disconnect/reconnect of the same store keeps every
@@ -108,12 +107,16 @@ export interface ShopifySyncSummary {
fetched: number
/** Refund objects seen on qualifying orders in the window. */
refundsFetched: number
/** New inbox rows inserted. */
imported: number
/** Rows skipped by external_id / content dedup. */
duplicates: number
/** Rows dropped because they are dated on/before the bookkeeping lock. */
skippedLocked: number
/** New webshop_orders rows inserted. */
inserted: number
/** Existing rows refreshed (status, refunds, FX). */
updated: number
/** Re-polled rows with nothing new. */
unchanged: number
/** Booked rows whose financials drifted remotely (flagged, not touched). */
frozenFlagged: number
/** Rows linked to a row the retired transactions feed already imported. */
crossMarked: number
errors: number
/** Set when the caller's time budget ran out before all pages processed. */
deadlineReached?: boolean
@@ -129,7 +132,7 @@ export interface ShopifySyncSummary {
*/
function parseAmount(value: string): number | null {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? roundOre(parsed) : null
return Number.isFinite(parsed) ? round(parsed) : null
}
/** Whether a qualifying order's total cannot be read as money. */
@@ -163,176 +166,289 @@ export function orderQualifies(
}
/**
* Map a paid order to its gross feed row. Dates use processedAt (when the
* money event happened), not createdAt: booked entries, invoice matching, and
* month boundaries all want the payment date. Descriptions are deterministic
* from immutable data (order names never change) because the content-dedup
* bridge keys off them.
* Tolerance (in the order's currency) for reconstruction drift. Derived nets
* (tax / rate) can be a few öre off per rate; anything inside the tolerance
* is left for the booking's 3740 residual line instead of fabricating a
* 0%-sale, and a larger gap means the data is telling us something real.
*/
export function mapOrder(shopScope: string, order: ShopifyOrder): RawTransaction[] {
if (!orderQualifies(order)) return []
const amount = parseAmount(order.totalPriceSet.shopMoney.amount)
if (amount === null || amount === 0) return []
return [
{
date: isoDateOf(order.processedAt),
description: `Shopify-order ${order.name}`,
amount,
currency: order.totalPriceSet.shopMoney.currencyCode.toUpperCase(),
external_id: shopifyOrderExternalId(shopScope, order.legacyResourceId),
import_source: SHOPIFY_IMPORT_SOURCE,
reference: order.paymentGatewayNames.join(', ') || null,
},
]
const VAT_REMAINDER_TOLERANCE = 0.5
/**
* Per-rate VAT buckets reconstructed from the ORDER-LEVEL taxLines. Shopify
* reports the tax charged per rate but no per-rate net, so the net is derived
* arithmetically: net = tax / rate. The derivation is exact to within öre
* rounding (Shopify computes each tax from its taxable net) and, unlike
* summing line items, immune to cart-level discount allocations, line-item
* pagination truncation and the taxesIncluded mode. Whatever the buckets do
* not cover (zero-rated goods, tips) becomes a 0%-bucket via the remainder
* against the charged total. Returns [] when the tax data is unusable (a
* charged tax without a reported rate, or buckets exceeding the total): the
* booking dialog then falls back to ratio inference, same as the WooCommerce
* hardened-store case.
*/
export function buildVatBreakdown(
order: Pick<ShopifyOrder, 'totalPriceSet' | 'taxLines'>,
): WebshopVatBreakdownLine[] {
const total = parseAmount(order.totalPriceSet.shopMoney.amount)
if (total === null || total === 0) return []
const buckets = new Map<number, { net: number; tax: number }>()
for (const taxLine of order.taxLines ?? []) {
const tax = parseAmount(taxLine.priceSet.shopMoney.amount)
if (tax === null || tax === 0) continue
// A charged tax whose rate Shopify does not report cannot be bucketed;
// a partial breakdown would book too little moms, so refuse the whole
// breakdown instead.
if (typeof taxLine.ratePercentage !== 'number' || taxLine.ratePercentage <= 0) {
return []
}
const rate = taxLine.ratePercentage
const bucket = buckets.get(rate) ?? { net: 0, tax: 0 }
bucket.net = round(bucket.net + tax / (rate / 100))
bucket.tax = round(bucket.tax + tax)
buckets.set(rate, bucket)
}
const breakdown = Array.from(buckets.entries())
.map(([rate, { net, tax }]) => ({ rate, net, tax }))
.sort((a, b) => b.rate - a.rate)
const covered = round(breakdown.reduce((sum, b) => sum + b.net + b.tax, 0))
const remainder = round(total - covered)
if (remainder < -VAT_REMAINDER_TOLERANCE) return []
if (remainder > VAT_REMAINDER_TOLERANCE) {
breakdown.push({ rate: 0, net: remainder, tax: 0 })
}
return breakdown
}
/** Map one refund of a paid order to its negative feed row. */
export function mapRefund(
shopScope: string,
order: Pick<ShopifyOrder, 'name'>,
/**
* VAT buckets for one refund: the PARENT order's breakdown prorated by
* refund/order ratio, so the VAT reversal follows the sale's actual mix and
* a refund never books without a moms reversal (the WooCommerce skeptic
* finding). Shopify's Refund object reports no per-rate tax without paging a
* refundLineItems connection per refund, so proration is the whole strategy
* here, not just the amount-only fallback it is for WooCommerce. Magnitudes
* are returned positive; row_type 'refund' carries the direction.
*
* When per-rate bucketing is refused (parent breakdown []), the parent's
* TOTAL tax is still prorated into totalTax: the refund row then carries the
* moms reversal through the booking dialog's ratio-inference fallback as an
* editable bucket, instead of silently prefilling a 0%-refund whose reversal
* never reaches 2611 (review finding, PR #1676).
*/
export function buildRefundVatBreakdown(
order: Pick<ShopifyOrder, 'totalPriceSet' | 'taxLines'>,
refund: ShopifyRefund,
): RawTransaction[] {
const amount = parseAmount(refund.totalRefundedSet.shopMoney.amount)
if (amount === null || amount === 0) return []
return [
{
date: isoDateOf(refund.createdAt),
description: `Shopify-återbetalning order ${order.name}`,
amount: -amount,
currency: refund.totalRefundedSet.shopMoney.currencyCode.toUpperCase(),
external_id: shopifyRefundExternalId(shopScope, refund.legacyResourceId),
import_source: SHOPIFY_IMPORT_SOURCE,
reference: null,
},
]
): { breakdown: WebshopVatBreakdownLine[]; totalTax: number } {
const orderBreakdown = buildVatBreakdown(order)
const orderTotal = Math.abs(parseAmount(order.totalPriceSet.shopMoney.amount) ?? 0)
const refundAmount = Math.abs(parseAmount(refund.totalRefundedSet.shopMoney.amount) ?? 0)
if (orderTotal === 0 || refundAmount === 0) {
return { breakdown: [], totalTax: 0 }
}
if (orderBreakdown.length === 0) {
const parentTax = partTax(order.taxLines ?? [])
const ratio = refundAmount / orderTotal
return { breakdown: [], totalTax: parentTax > 0 ? round(parentTax * ratio) : 0 }
}
// Per-bucket rounding drift lands on the booking's 3740 residual line.
const ratio = refundAmount / orderTotal
const breakdown = orderBreakdown
.map(({ rate, net, tax }) => ({
rate,
net: round(net * ratio),
tax: round(tax * ratio),
}))
.filter(({ net, tax }) => net !== 0 || tax !== 0)
const totalTax = round(breakdown.reduce((sum, b) => sum + b.tax, 0))
return { breakdown, totalTax }
}
/** Company lock date (YYYY-MM-DD) or null; read once per run. */
async function fetchLockThrough(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
return (
(settings as { bookkeeping_locked_through?: string | null } | null)
?.bookkeeping_locked_through ?? null
/** Sum of one part's tax lines. */
function partTax(taxLines: ShopifyTaxLine[]): number {
return round(
taxLines.reduce((sum, t) => sum + (parseAmount(t.priceSet.shopMoney.amount) ?? 0), 0),
)
}
/** Whether a feed-row date is on/before the lock date (=> never bookable). */
export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean {
return lockThrough !== null && rowDate <= lockThrough
/** The part's single VAT rate, 0 when untaxed, null when mixed/unreported. */
function partRate(taxLines: ShopifyTaxLine[]): number | null {
const rates = new Set<number>()
for (const line of taxLines) {
if ((parseAmount(line.priceSet.shopMoney.amount) ?? 0) === 0) continue
if (typeof line.ratePercentage !== 'number') return null
rates.add(line.ratePercentage)
}
if (rates.size === 0) return 0
return rates.size === 1 ? Array.from(rates)[0] : null
}
/**
* Window start (ISO, UTC) for the updated_at filter. With a cursor: cursor
* minus the 24h overlap. First run: BACKFILL_DAYS back. (The lock date does
* not floor the window: it selects on updatedAt while rows are dated by
* processedAt, so the real guard is rowBehindLock at map time, every run.)
* The stored line snapshot covers EVERYTHING inside order.total (product
* lines + shipping) or nothing. The invoice conversion builds its rows from
* this snapshot, so a diverging one would silently bill the customer the
* wrong amount (the WooCommerce skeptic finding, one platform over). Two
* things make Shopify's parts diverge: truncated line-item pages, and
* cart-level discounts (discountedTotalSet only subtracts line-level
* discounts). Both are caught by the öre-exact sum check below; a dropped
* snapshot falls back to one aggregate order line at conversion time, which
* is always total-correct. Line totals are stored NET (the invoice
* conversion applies vat_rate on top), decomposed per the shop's
* taxesIncluded mode.
*/
function resolveWindowStartIso(connection: ShopifyConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
export function mapLineItems(order: ShopifyOrder): WebshopOrderLineItem[] {
if (order.lineItems.pageInfo.hasNextPage || order.shippingLines.pageInfo.hasNextPage) {
return []
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
const items: WebshopOrderLineItem[] = []
for (const item of order.lineItems.nodes) {
const base = parseAmount(item.discountedTotalSet.shopMoney.amount)
if (base === null) return []
const tax = partTax(item.taxLines)
items.push({
name: item.name,
quantity: item.quantity,
total: order.taxesIncluded ? round(base - tax) : base,
total_tax: tax,
vat_rate: partRate(item.taxLines),
})
}
for (const line of order.shippingLines.nodes) {
const base = parseAmount(line.discountedPriceSet.shopMoney.amount)
if (base === null) return []
const tax = partTax(line.taxLines)
if (base === 0 && tax === 0) continue
items.push({
name: line.title || 'Frakt',
quantity: 1,
total: order.taxesIncluded ? round(base - tax) : base,
total_tax: tax,
vat_rate: partRate(line.taxLines),
})
}
const total = parseAmount(order.totalPriceSet.shopMoney.amount) ?? 0
const covered = round(items.reduce((sum, i) => sum + i.total + i.total_tax, 0))
if (Math.abs(covered - total) > 0.005) return []
return items
}
/**
* Make sure the store cash account exists (ledger 1584, source manual so a
* later remap/promotion follows the normal cash-account rules) and, on the
* first run, that 1584 exists in the chart of accounts: the booking dialog
* and AccountPicker only list chart accounts.
*
* Currency comes from the shop settings read at connect time, falling back to
* the first fetched order's real currency (guessing SEK for an EUR store
* would poison the account). A conflict with an existing 1584 cash account
* throws; the caller surfaces that on the connection so the panel shows why
* nothing syncs.
*/
async function ensureStoreAccount(
supabase: SupabaseClient,
connection: ShopifyConnection,
fallbackCurrency: string | undefined,
firstRun: boolean,
log: Logger,
): Promise<void> {
const currency =
connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK'
try {
await ensureManualCashAccount(
supabase,
connection.company_id,
SHOPIFY_LEDGER_ACCOUNT,
currency,
'Shopify-saldo',
)
} catch (accountError) {
// Typically a currency conflict with an existing 1584 cash account. Made
// visible on the connection: without this the panel shows a healthy
// "Ansluten" store that silently never syncs.
await supabase
.from('shopify_connections')
.update({
error_message:
'Kassakontot för butiken (1584) kunde inte skapas. Kontrollera att befintligt konto 1584 har samma valuta som butiken.',
})
.eq('id', connection.id)
throw accountError
}
if (firstRun) {
const sync = await syncMappedAccounts(
supabase,
connection.company_id,
connection.user_id,
[
{
sourceAccount: SHOPIFY_LEDGER_ACCOUNT,
sourceName: SHOPIFY_LEDGER_ACCOUNT_NAME,
targetAccount: SHOPIFY_LEDGER_ACCOUNT,
targetName: SHOPIFY_LEDGER_ACCOUNT_NAME,
confidence: 1,
matchType: 'exact',
isOverride: false,
},
],
false,
)
if (sync.error) {
// Rows still import and bind to the cash account; only the chart
// listing is affected (the account can be added manually), so this is
// deliberately non-fatal.
log.warn('chart sync for 1584 failed', {
companyId: connection.company_id,
error: sync.error,
})
}
/** Sum of refund totals (positive) reported inline on the order. */
function refundedTotal(order: ShopifyOrder): number {
let sum = 0
for (const refund of order.refunds ?? []) {
const amount = parseAmount(refund.totalRefundedSet.shopMoney.amount)
if (amount !== null) sum = round(sum + Math.abs(amount))
}
return sum
}
/** Rows for one page of orders: gross rows plus inline refund rows. */
/** Row status: the financial status lowercased (paid, partially_refunded, …). */
function orderStatus(order: ShopifyOrder): string {
return (order.displayFinancialStatus ?? 'paid').toLowerCase()
}
/** Map one paid order to its webshop_orders upsert row. */
export function mapOrderToWebshopRow(
connection: Pick<ShopifyConnection, 'id' | 'shop_name'>,
shopScope: string,
order: ShopifyOrder,
): WebshopOrderUpsert[] {
if (!orderQualifies(order)) return []
const total = parseAmount(order.totalPriceSet.shopMoney.amount)
// Zero-total orders (100% discount) carry no bookable money event;
// importing them would strand an unbookable "Att bokföra" row (the engine
// refuses zero-sum entries).
if (total === null || total === 0) return []
return [
{
platform: 'shopify',
store_scope: shopScope,
store_label: connection.shop_name,
connection_id: connection.id,
row_type: 'order',
parent_external_id: null,
external_id: shopifyOrderExternalId(shopScope, order.legacyResourceId),
platform_order_id: order.legacyResourceId,
order_number: order.name,
status: orderStatus(order),
is_paid: true,
order_date: isoDateOf(order.createdAt),
paid_date: isoDateOf(order.processedAt),
currency: order.totalPriceSet.shopMoney.currencyCode.toUpperCase(),
total,
total_tax: partTax(order.taxLines),
vat_breakdown: buildVatBreakdown(order),
line_items: mapLineItems(order),
// Deliberately null (v1): customer fields sit behind Shopify's
// protected customer data program. The Orders page shows "–" and
// "Skapa faktura" starts without a prefilled customer.
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: null,
payment_method: order.paymentGatewayNames[0] ?? null,
payment_method_title: order.paymentGatewayNames.join(', ') || null,
gateway_reference: null,
refunded_total: refundedTotal(order),
},
]
}
/** Map one refund of a paid order to its negative upsert row. */
export function mapRefundToWebshopRow(
connection: Pick<ShopifyConnection, 'id' | 'shop_name'>,
shopScope: string,
order: ShopifyOrder,
refund: ShopifyRefund,
): WebshopOrderUpsert[] {
const amount = parseAmount(refund.totalRefundedSet.shopMoney.amount)
if (amount === null || amount === 0) return []
const { breakdown, totalTax } = buildRefundVatBreakdown(order, refund)
return [
{
platform: 'shopify',
store_scope: shopScope,
store_label: connection.shop_name,
connection_id: connection.id,
row_type: 'refund',
parent_external_id: shopifyOrderExternalId(shopScope, order.legacyResourceId),
external_id: shopifyRefundExternalId(shopScope, refund.legacyResourceId),
platform_order_id: refund.legacyResourceId,
order_number: order.name,
status: 'refund',
is_paid: true,
order_date: isoDateOf(refund.createdAt),
paid_date: isoDateOf(refund.createdAt),
currency: refund.totalRefundedSet.shopMoney.currencyCode.toUpperCase(),
total: -Math.abs(amount),
total_tax: -totalTax,
vat_breakdown: breakdown,
line_items: [],
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: null,
payment_method: order.paymentGatewayNames[0] ?? null,
payment_method_title: order.paymentGatewayNames.join(', ') || null,
gateway_reference: null,
refunded_total: 0,
},
]
}
/** Upsert rows for one page of orders: order rows plus inline refund rows. */
function buildPageRows(
connection: ShopifyConnection,
shopScope: string,
orders: ShopifyOrder[],
lockThrough: string | null,
summary: ShopifySyncSummary,
log: Logger,
): RawTransaction[] {
const rows: RawTransaction[] = []
const push = (mapped: RawTransaction[]) => {
for (const row of mapped) {
if (rowBehindLock(row.date, lockThrough)) {
summary.skippedLocked += 1
continue
}
rows.push(row)
}
}
): WebshopOrderUpsert[] {
const rows: WebshopOrderUpsert[] = []
for (const order of orders) {
// A corrupt total is counted and logged, never silently identical to a
@@ -346,10 +462,10 @@ function buildPageRows(
total: order.totalPriceSet.shopMoney.amount,
})
}
push(mapOrder(shopScope, order))
rows.push(...mapOrderToWebshopRow(connection, shopScope, order))
// Refunds only exist in the feed for qualifying (paid) orders: a refund
// row without its gross counterpart would be an unexplainable negative in
// the inbox. They come inline on the order (no follow-up request).
// row without its parent would be an unexplainable negative. They come
// inline on the order (no follow-up request).
if (!orderQualifies(order)) continue
for (const refund of order.refunds) {
summary.refundsFetched += 1
@@ -361,12 +477,24 @@ function buildPageRows(
amount: refund.totalRefundedSet.shopMoney.amount,
})
}
push(mapRefund(shopScope, order, refund))
rows.push(...mapRefundToWebshopRow(connection, shopScope, order, refund))
}
}
return rows
}
/**
* Window start (ISO, UTC) for the updated_at filter. With a cursor: cursor
* minus the 24h overlap. First run: BACKFILL_DAYS back.
*/
function resolveWindowStartIso(connection: ShopifyConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
}
export async function syncShopifyOrders(
supabase: SupabaseClient,
connection: ShopifyConnection,
@@ -381,9 +509,11 @@ export async function syncShopifyOrders(
const summary: ShopifySyncSummary = {
fetched: 0,
refundsFetched: 0,
imported: 0,
duplicates: 0,
skippedLocked: 0,
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
}
if (
@@ -395,8 +525,6 @@ export async function syncShopifyOrders(
}
const shopScope = shopifyShopScope(connection.shop_domain)
const firstRun = !connection.last_order_synced_at
const lockThrough = await fetchLockThrough(supabase, connection.company_id)
const runStartMs = Date.now()
const updatedAtMin = resolveWindowStartIso(connection)
@@ -408,7 +536,6 @@ export async function syncShopifyOrders(
let failureFloorMs = Number.POSITIVE_INFINITY
// True once the whole window was listed to its end (empty page or last page).
let windowExhausted = false
let accountEnsured = false
try {
// Token exchange happens up front (the token lives ~24h, far longer than
@@ -421,7 +548,7 @@ export async function syncShopifyOrders(
summary.deadlineReached = true
log.info('time budget exhausted; stopping order sync', {
connectionId: connection.id,
processed: summary.imported + summary.duplicates,
processed: summary.inserted + summary.updated + summary.unchanged,
})
break
}
@@ -433,44 +560,28 @@ export async function syncShopifyOrders(
}
summary.fetched += page.orders.length
// Deferred until the window is known non-empty so a quiet store costs
// one API call and zero DB writes; also gives us a real order currency
// as the fallback when the shop currency was unreadable at connect.
if (!accountEnsured) {
await ensureStoreAccount(
supabase,
connection,
page.orders[0].totalPriceSet.shopMoney.currencyCode,
firstRun,
log,
)
accountEnsured = true
}
const rows = buildPageRows(shopScope, page.orders, lockThrough, summary, log)
const rows = buildPageRows(connection, shopScope, page.orders, summary, log)
const firstMs = Date.parse(page.orders[0].updatedAt)
const lastMs = Date.parse(page.orders[page.orders.length - 1].updatedAt)
if (rows.length > 0) {
// Auto-categorization is skipped on purpose: booking Shopify money is
// a human decision in the inbox (feed-only doctrine, same as the
// Stripe and WooCommerce feeds). Invoice matching still runs
// (suggestions only), and FX enrichment covers non-SEK stores.
const result = await ingestTransactions(
const result = await upsertWebshopOrders(
supabase,
connection.company_id,
connection.user_id,
rows,
{ settlementAccount: SHOPIFY_LEDGER_ACCOUNT, skipAutoCategorization: true },
)
summary.imported += result.imported
summary.duplicates += result.duplicates
summary.inserted += result.inserted
summary.updated += result.updated
summary.unchanged += result.unchanged
summary.frozenFlagged += result.frozenFlagged
summary.crossMarked += result.crossMarked
summary.errors += result.errors
if (result.errors > 0) {
// Failed inserts are dropped inside ingest; hold the cursor below
// this page so the next run re-lists and retries it rather than
// turning a transient DB error into permanently missing rows.
// Failed upserts are dropped inside the service; hold the cursor
// below this page so the next run re-lists and retries it rather
// than turning a transient DB error into permanently missing rows.
failureFloorMs = Math.min(failureFloorMs, firstMs - 1000)
}
}
@@ -550,12 +661,6 @@ export async function syncShopifyOrders(
throw err
}
if (summary.skippedLocked > 0) {
log.info('rows behind the bookkeeping lock were skipped', {
connectionId: connection.id,
skippedLocked: summary.skippedLocked,
})
}
log.info('shopify order sync done', {
connectionId: connection.id,
...summary,
@@ -107,12 +107,13 @@ export interface ShopifySyncPayload {
transactions?: {
fetched?: number
refundsFetched?: number
imported?: number
duplicates?: number
inserted?: number
updated?: number
unchanged?: number
errors?: number
revoked?: boolean
deadlineReached?: boolean
}
} | null
}
type SyncCounts = {
@@ -148,7 +149,8 @@ export function syncSummary(payload: ShopifySyncPayload | null): ShopifySyncOutc
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
const fetched = summary.fetched
const imported = typeof summary.imported === 'number' ? summary.imported : 0
// "imported" in the user-facing sentence = new rows this run (inserts).
const imported = typeof summary.inserted === 'number' ? summary.inserted : 0
const errors = typeof summary.errors === 'number' ? summary.errors : 0
if (summary.deadlineReached === true) {
+2 -2
View File
@@ -13,7 +13,7 @@
"icon": "ShoppingBag",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Hämta betalda ordrar och återbetalningar från din Shopify-butik till transaktionsinkorgen",
"longDescription": "Anslut din Shopify-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner."
"description": "Hämta betalda ordrar och återbetalningar från din Shopify-butik till Ordersidan",
"longDescription": "Anslut din Shopify-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till Ordersidan, med belopp, betalsätt och moms per sats. Inget bokförs automatiskt: du bokför varje order själv från Ordersidan."
}
}
+42 -2
View File
@@ -61,6 +61,34 @@ export interface ShopifyRefund {
totalRefundedSet: ShopifyMoneyBag
}
/** One tax charged on the order or on one of its parts. */
export interface ShopifyTaxLine {
/** Rate as a percent (25.0); null on legacy rows where Shopify omits it. */
ratePercentage: number | null
/** Tax amount charged at this rate. */
priceSet: ShopifyMoneyBag
}
/** One product line of an order. */
export interface ShopifyLineItem {
name: string
quantity: number
/**
* Line total after line-level discounts (cart-level discount allocations
* are NOT subtracted). Includes tax iff the order's taxesIncluded is true.
*/
discountedTotalSet: ShopifyMoneyBag
taxLines: ShopifyTaxLine[]
}
/** One shipping line of an order. */
export interface ShopifyShippingLine {
title: string | null
/** Shipping price after discounts; includes tax iff taxesIncluded. */
discountedPriceSet: ShopifyMoneyBag
taxLines: ShopifyTaxLine[]
}
/**
* Minimal GraphQL Admin API order shape consumed by the feed. All timestamps
* are ISO 8601 UTC with a Z suffix.
@@ -72,14 +100,26 @@ export interface ShopifyOrder {
name: string
/** Test-gateway order (dev stores, Bogus Gateway); never real revenue. */
test: boolean
/** When payment was captured; the feed's row date. */
/** When the order was created in Shopify; the row's order_date. */
createdAt: string
/** When payment was captured; the row's paid_date. */
processedAt: string
updatedAt: string
displayFinancialStatus: string | null
/** Gateway display names; join key for gateway-side reconciliation. */
/** Gateway display names; the booking dialog's payment-method map key. */
paymentGatewayNames: string[]
/**
* Whether the store's prices include tax (typical Swedish B2C store).
* Governs how line/shipping totals decompose into net + tax.
*/
taxesIncluded: boolean
/** Grand total actually charged (gross, incl. tax and shipping). */
totalPriceSet: ShopifyMoneyBag
/** Per-rate tax charged on the whole order; the vat_breakdown source. */
taxLines: ShopifyTaxLine[]
/** First page of line items; hasNextPage means the snapshot is incomplete. */
lineItems: { pageInfo: { hasNextPage: boolean }; nodes: ShopifyLineItem[] }
shippingLines: { pageInfo: { hasNextPage: boolean }; nodes: ShopifyShippingLine[] }
refunds: ShopifyRefund[]
}