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
+2
View File
@@ -1047,4 +1047,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line).
[2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list.
[2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03.
[2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund).
[2026-08-18] Shopify order feed keeps its paid-only qualification (PAID/PARTIALLY_REFUNDED/REFUNDED) after the webshop_orders port, unlike WooCommerce which also imports unpaid orders for the invoice flow: widening qualification is a product decision, out of scope for the port; unpaid orders re-surface via updatedAt when payment captures. The line-item snapshot is stored only when the parts reconstruct the charged total to the ore (else [] and the invoice conversion falls back to one aggregate line), and the bookkeeping-lock row filter was dropped: an Orders-page row behind the lock is an overview row, not permanent inbox noise, and booking is still blocked by the lock triggers (parity with WooCommerce).
[2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows.
+11 -11
View File
@@ -180,21 +180,21 @@ export default async function DashboardLayout({
// preference (Inställningar → Assistenten). Batched here so it costs no
// extra round-trip on the dashboard critical path.
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
// Whether the company has a webshop hooked up: an ACTIVE WooCommerce
// connection, or already-imported webshop_orders rows (a disconnected
// store's orders are accounting underlag and must stay reachable).
// Shopify connections deliberately do NOT count until the Shopify sync
// is switched from the transactions feed to webshop_orders: gating on
// them today would surface a permanently empty Orders page. Two
// indexed limit-1 selects, parallel with the batch; accepted cost on
// the first-paint path (gates a nav destination, unlike the badge
// counts that moved client-side above).
// Whether the company has a webshop hooked up: an ACTIVE WooCommerce or
// Shopify connection, or already-imported webshop_orders rows (a
// disconnected store's orders are accounting underlag and must stay
// reachable). Three indexed limit-1 selects, parallel with the batch;
// accepted cost on the first-paint path (gates a nav destination, unlike
// the badge counts that moved client-side above).
Promise.all([
supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1),
]).then(
([woo, orders]) =>
(woo.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0,
([woo, shopify, orders]) =>
(woo.data?.length ?? 0) > 0 ||
(shopify.data?.length ?? 0) > 0 ||
(orders.data?.length ?? 0) > 0,
),
// Whether the company already has mileage trips: OR-ed with the
// mileage_enabled settings toggle below so trips created via API/MCP can
+4 -1
View File
@@ -189,7 +189,10 @@ export default function OrdersPage() {
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/import?mode=woocommerce"
// The import hub's e-handel section lists every connectable
// platform; deep-linking one platform's connect panel here would
// send Shopify users into the WooCommerce flow.
actionHref="/import"
/>
) : (
<div className="stagger-enter overflow-x-auto">
@@ -61,9 +61,11 @@ beforeEach(() => {
syncShopifyOrders.mockResolvedValue({
fetched: 2,
refundsFetched: 0,
imported: 2,
duplicates: 0,
skippedLocked: 0,
inserted: 2,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
})
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
@@ -109,7 +111,7 @@ describe('GET /api/extensions/shopify/orders/cron', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.processed).toBe(2)
expect(body.imported).toBe(4)
expect(body.inserted).toBe(4)
expect(syncShopifyOrders).toHaveBeenCalledTimes(2)
// Runs on the service client with a shared deadline.
expect(syncShopifyOrders.mock.calls[0][0]).toBeTruthy()
@@ -134,9 +136,11 @@ describe('GET /api/extensions/shopify/orders/cron', () => {
.mockResolvedValueOnce({
fetched: 1,
refundsFetched: 0,
imported: 1,
duplicates: 0,
skippedLocked: 0,
inserted: 1,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
})
const res = await callRoute()
@@ -150,9 +154,11 @@ describe('GET /api/extensions/shopify/orders/cron', () => {
syncShopifyOrders.mockResolvedValue({
fetched: 0,
refundsFetched: 0,
imported: 0,
duplicates: 0,
skippedLocked: 0,
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
revoked: true,
})
+14 -14
View File
@@ -15,13 +15,13 @@ export const maxDuration = 300
/**
* GET /api/extensions/shopify/orders/cron
* Nightly order sync for connections that opted in (transaction_sync_enabled):
* imports each connected store's paid orders and refunds into the
* transactions inbox as a bank-style feed on the 1584 cash account.
* upserts each connected store's paid orders and refunds into webshop_orders
* (the Orders page), replacing the earlier transactions-inbox feed.
*
* Read-only against the stores, and it never posts to the journal: rows land
* unbooked; booking stays a human decision. Idempotent via the
* (company_id, external_id) unique index, so overlapping windows and re-runs
* are no-ops. Emits no events, so no ensureInitialized() is needed.
* unbooked; booking stays a human decision on the Orders page. Idempotent via
* the (company_id, external_id) unique index; overlap re-polls become status
* updates. Emits no events, so no ensureInitialized() is needed.
*/
export const GET = withCronContext('cron.shopify_order_sync', async (_request, ctx) => {
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
@@ -84,8 +84,8 @@ export const GET = withCronContext('cron.shopify_order_sync', async (_request, c
const results: Array<{
connectionId: string
imported: number
duplicates: number
inserted: number
updated: number
status: 'synced' | 'revoked' | 'error'
}> = []
@@ -109,8 +109,8 @@ export const GET = withCronContext('cron.shopify_order_sync', async (_request, c
}
results.push({
connectionId: connection.id,
imported: summary.imported,
duplicates: summary.duplicates,
inserted: summary.inserted,
updated: summary.updated,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
@@ -120,19 +120,19 @@ export const GET = withCronContext('cron.shopify_order_sync', async (_request, c
})
results.push({
connectionId: connection.id,
imported: 0,
duplicates: 0,
inserted: 0,
updated: 0,
status: 'error',
})
}
}
const totalImported = results.reduce((acc, r) => acc + r.imported, 0)
const totalInserted = results.reduce((acc, r) => acc + r.inserted, 0)
ctx.log.info('shopify order sync summary', {
processed: results.length,
totalImported,
totalInserted,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, imported: totalImported, results })
return NextResponse.json({ processed: results.length, inserted: totalInserted, results })
})
@@ -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[]
}
+2 -2
View File
@@ -165,8 +165,8 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"category": "import",
"icon": "ShoppingBag",
"dataPattern": "manual",
"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.",
"hasOwnData": true
},
{
+7 -7
View File
@@ -507,7 +507,7 @@
},
"shopify": {
"title": "Shopify",
"description": "Connect your Shopify store to fetch paid orders and refunds into the transaction inbox, as a bank-style feed for the store. You book the rows from the inbox as usual. Note that an order can mix VAT rates (25/12/6%); split the VAT accordingly when you book the row.",
"description": "Connect your Shopify store to fetch paid orders and refunds to the Orders page every night, with amounts, payment methods and VAT per rate. You book the orders from there.",
"not_configured": "The Shopify integration is not configured on this installation. Contact your administrator.",
"load_failed": "Could not read the Shopify status. Check your connection and try again.",
"action_timeout": "The action took too long. Reload the page to see whether it went through.",
@@ -536,16 +536,16 @@
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_feed": "{fetched} order(s) fetched: {imported} new rows in the inbox.",
"sync_done_feed": "{fetched} order(s) fetched: {imported} new on the Orders page.",
"sync_done_empty": "The store returned no orders for the period. Check that the right store is connected if you expected orders.",
"sync_done_feed_errors": "{fetched} order(s) fetched: {imported} new rows in the inbox. {errors} row(s) could not be imported: sync again.",
"sync_done_feed_errors": "{fetched} order(s) fetched: {imported} new on the Orders page. {errors} row(s) could not be imported: sync again.",
"sync_partial_title": "Sync paused",
"sync_partial": "{fetched} order(s) fetched so far: {imported} new rows in the inbox.{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all orders were fetched in time: sync again to continue where it stopped.",
"sync_partial": "{fetched} order(s) fetched so far: {imported} new on the Orders page.{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all orders were fetched in time: sync again to continue where it stopped.",
"sync_failed_title": "Sync failed",
"sync_revoked": "The store rejected the app credentials, so no orders could be fetched. Connect the store again.",
"transaction_sync_title": "Orders from Shopify",
"transaction_sync_description": "Fetch the store's paid orders and refunds into the transaction inbox every night, as a bank-style feed for the store. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock.",
"transaction_sync_description": "Fetch the store's paid orders and refunds to the Orders page every night. You book the orders from there.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history.",
"transaction_sync_last_synced": "Last synced {date}",
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Order sync enabled. History is fetched on the next sync.",
@@ -7089,7 +7089,7 @@
"woocommerce_not_enabled_title": "The WooCommerce extension is not enabled",
"woocommerce_not_enabled_description": "Enable the WooCommerce extension to connect your store and fetch orders automatically.",
"shopify_title": "Shopify",
"shopify_description": "Connect your Shopify store to fetch paid orders and refunds into the transaction inbox.",
"shopify_description": "Connect your Shopify store to fetch paid orders and refunds to the Orders page.",
"shopify_not_enabled_title": "The Shopify extension is not enabled",
"shopify_not_enabled_description": "Enable the Shopify extension to connect your store and fetch orders automatically.",
"migration_title": "Import from another system",
+7 -7
View File
@@ -507,7 +507,7 @@
},
"shopify": {
"title": "Shopify",
"description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen. Observera att en order kan innehålla flera momssatser (25/12/6 %); dela upp momsen därefter när du bokför raden.",
"description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till Ordersidan varje natt, med belopp, betalsätt och moms per sats. Du bokför ordrarna därifrån.",
"not_configured": "Shopify-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
"load_failed": "Kunde inte läsa Shopify-statusen. Kontrollera din uppkoppling och försök igen.",
"action_timeout": "Åtgärden tog för lång tid. Ladda om sidan för att se om den gick igenom.",
@@ -536,16 +536,16 @@
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_feed": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen.",
"sync_done_feed": "{fetched} order/ordrar hämtade: {imported} nya på Ordersidan.",
"sync_done_empty": "Butiken returnerade inga ordrar för perioden. Kontrollera att rätt butik är ansluten om du väntade dig ordrar.",
"sync_done_feed_errors": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen. {errors} rad(er) kunde inte importeras: synka igen.",
"sync_done_feed_errors": "{fetched} order/ordrar hämtade: {imported} nya på Ordersidan. {errors} rad(er) kunde inte importeras: synka igen.",
"sync_partial_title": "Synkroniseringen pausades",
"sync_partial": "{fetched} order/ordrar hämtade hittills: {imported} nya rader i inkorgen.{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla ordrar hann inte hämtas: synka igen för att fortsätta där det stannade.",
"sync_partial": "{fetched} order/ordrar hämtade hittills: {imported} nya på Ordersidan.{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla ordrar hann inte hämtas: synka igen för att fortsätta där det stannade.",
"sync_failed_title": "Synkroniseringen misslyckades",
"sync_revoked": "Butiken avvisade appens uppgifter, så inga ordrar kunde hämtas. Anslut butiken igen.",
"transaction_sync_title": "Ordrar från Shopify",
"transaction_sync_description": "Hämta butikens betalda ordrar och återbetalningar till transaktionsinkorgen varje natt, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
"transaction_sync_description": "Hämta butikens betalda ordrar och återbetalningar till Ordersidan varje natt. Du bokför ordrarna därifrån.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik.",
"transaction_sync_last_synced": "Senast synkad {date}",
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Ordersynk aktiverad. Historiken hämtas vid nästa synkning.",
@@ -7089,7 +7089,7 @@
"woocommerce_not_enabled_title": "WooCommerce-tillägget är inte aktiverat",
"woocommerce_not_enabled_description": "Aktivera tillägget WooCommerce för att koppla din butik och hämta ordrar automatiskt.",
"shopify_title": "Shopify",
"shopify_description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.",
"shopify_description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till Ordersidan.",
"shopify_not_enabled_title": "Shopify-tillägget är inte aktiverat",
"shopify_not_enabled_description": "Aktivera tillägget Shopify för att koppla din butik och hämta ordrar automatiskt.",
"migration_title": "Hämta från annat system",