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:
co-authored by
Claude Fable 5
parent
cfdddb2d7e
commit
bc357531cc
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user