Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed). Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw. What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body. Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows. Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
buildVatBreakdown,
|
|
fromMinor,
|
|
mapLineItems,
|
|
mapPurchaseToWebshopRows,
|
|
purchaseQualifiesAsPaidSale,
|
|
purchaseQualifiesAsRefund,
|
|
unsupportedReason,
|
|
zettlePurchaseExternalId,
|
|
zettleStoreScope,
|
|
} from '../lib/order-sync'
|
|
import type { ZettlePurchase } from '../types'
|
|
|
|
function sale(overrides: Partial<ZettlePurchase> = {}): ZettlePurchase {
|
|
return {
|
|
purchaseUUID1: '11111111-1111-1111-1111-111111111111',
|
|
purchaseNumber: 42,
|
|
globalPurchaseNumber: 42,
|
|
amount: 12500,
|
|
vatAmount: 2500,
|
|
currency: 'SEK',
|
|
country: 'SE',
|
|
created: '2026-09-01T12:00:00.000+0000',
|
|
refund: false,
|
|
products: [
|
|
{
|
|
quantity: '1',
|
|
type: 'PRODUCT',
|
|
name: 'Kaffe',
|
|
vatPercentage: 25,
|
|
rowTaxableAmount: 10000,
|
|
},
|
|
],
|
|
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-1', amount: 12500 }],
|
|
groupedVatAmounts: { '25.0': 2500 },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('zettle order-sync mapping', () => {
|
|
it('freezes the external_id template', () => {
|
|
expect(zettleStoreScope('org-abc')).toBe('org-abc')
|
|
expect(zettlePurchaseExternalId('org-abc', 'p-1')).toBe('zettle_org-abc_purchase_p-1')
|
|
})
|
|
|
|
it('qualifies card sales and rejects invoice-only purchases', () => {
|
|
expect(purchaseQualifiesAsPaidSale(sale())).toBe(true)
|
|
expect(
|
|
purchaseQualifiesAsPaidSale(
|
|
sale({ payments: [{ type: 'IZETTLE_INVOICE', amount: 12500 }] }),
|
|
),
|
|
).toBe(false)
|
|
expect(purchaseQualifiesAsPaidSale(sale({ amount: 0 }))).toBe(false)
|
|
})
|
|
|
|
it('qualifies refunds by the refund flag', () => {
|
|
expect(
|
|
purchaseQualifiesAsRefund(
|
|
sale({
|
|
refund: true,
|
|
amount: -12500,
|
|
vatAmount: -2500,
|
|
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
|
|
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
|
|
}),
|
|
),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('builds VAT breakdown from groupedVatAmounts', () => {
|
|
expect(buildVatBreakdown(sale())).toEqual([{ rate: 25, net: 100, tax: 25 }])
|
|
})
|
|
|
|
it('maps line items that reconstruct the charged total', () => {
|
|
const lines = mapLineItems(sale())
|
|
expect(lines).toHaveLength(1)
|
|
expect(lines[0]).toMatchObject({ name: 'Kaffe', quantity: 1, total: 100, total_tax: 25 })
|
|
})
|
|
|
|
it('maps a paid sale into a webshop order row with underlag', () => {
|
|
const rows = mapPurchaseToWebshopRows(
|
|
{ id: 'conn-1', organization_name: 'Caféet' },
|
|
'org-1',
|
|
sale(),
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]).toMatchObject({
|
|
platform: 'zettle',
|
|
row_type: 'order',
|
|
is_paid: true,
|
|
total: 125,
|
|
total_tax: 25,
|
|
order_number: '42',
|
|
payment_method: 'IZETTLE_CARD',
|
|
external_id: 'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
|
|
})
|
|
expect(rows[0].line_items).toHaveLength(1)
|
|
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 100, tax: 25 }])
|
|
})
|
|
|
|
it('maps a refund with parent external_id', () => {
|
|
const rows = mapPurchaseToWebshopRows(
|
|
{ id: 'conn-1', organization_name: 'Caféet' },
|
|
'org-1',
|
|
sale({
|
|
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
|
|
refund: true,
|
|
amount: -12500,
|
|
vatAmount: -2500,
|
|
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
|
|
products: [
|
|
{
|
|
quantity: '-1',
|
|
type: 'PRODUCT',
|
|
name: 'Kaffe',
|
|
vatPercentage: 25,
|
|
rowTaxableAmount: -10000,
|
|
},
|
|
],
|
|
groupedVatAmounts: { '25.0': -2500 },
|
|
}),
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0].row_type).toBe('refund')
|
|
expect(rows[0].total).toBe(-125)
|
|
expect(rows[0].parent_external_id).toBe(
|
|
'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
|
|
)
|
|
})
|
|
|
|
it('keeps line items and row-derived net when Zettle rounding drifts one öre per row', () => {
|
|
// 33.37 kr at 25%: Zettle net 26.70, re-derived tax 6.68, row sum 33.38.
|
|
const drifted = sale({
|
|
amount: 3337,
|
|
vatAmount: 667,
|
|
products: [
|
|
{ quantity: '1', type: 'PRODUCT', name: 'Bulle', vatPercentage: 25, rowTaxableAmount: 2670 },
|
|
],
|
|
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-2', amount: 3337 }],
|
|
groupedVatAmounts: { '25.0': 667 },
|
|
})
|
|
expect(mapLineItems(drifted)).toHaveLength(1)
|
|
// Net comes from the row (26.70), not tax / rate (26.68).
|
|
expect(buildVatBreakdown(drifted)).toEqual([{ rate: 25, net: 26.7, tax: 6.67 }])
|
|
// Two öre off on a single row is not rounding: drop the snapshot.
|
|
expect(mapLineItems(sale({ amount: 3340, products: drifted.products }))).toEqual([])
|
|
})
|
|
|
|
it('falls back to tax / rate when rows carry no net', () => {
|
|
expect(buildVatBreakdown(sale({ products: [] }))).toEqual([{ rate: 25, net: 100, tax: 25 }])
|
|
})
|
|
|
|
it('imports sales the row model cannot book as unpaid needs_review rows', () => {
|
|
const conn = { id: 'conn-1', organization_name: 'Caféet' }
|
|
const split = mapPurchaseToWebshopRows(conn, 'org-1', sale({
|
|
payments: [
|
|
{ type: 'IZETTLE_CARD', uuid: 'p1', amount: 7500 },
|
|
{ type: 'IZETTLE_CASH', uuid: 'p2', amount: 5000 },
|
|
],
|
|
}))
|
|
expect(split[0]).toMatchObject({
|
|
is_paid: false,
|
|
status: 'needs_review',
|
|
payment_method_title: 'Delad betalning: bokför manuellt',
|
|
})
|
|
// Card + unpaid invoice split must not count as paid either.
|
|
expect(unsupportedReason(sale({
|
|
payments: [{ type: 'IZETTLE_CARD', amount: 5000 }, { type: 'IZETTLE_INVOICE', amount: 7500 }],
|
|
}))).toBe('split_tender')
|
|
expect(unsupportedReason(sale({ payments: [{ type: 'GIFTCARD', amount: 12500 }] }))).toBe(
|
|
'voucher_tender',
|
|
)
|
|
expect(unsupportedReason(sale({
|
|
products: [{ quantity: '1', type: 'GIFTCARD', name: 'Presentkort', vatPercentage: 0, rowTaxableAmount: 12500 }],
|
|
}))).toBe('giftcard_sale')
|
|
expect(unsupportedReason(sale({
|
|
payments: [{ type: 'IZETTLE_CARD', amount: 12500, gratuityAmount: 1000 }],
|
|
}))).toBe('gratuity')
|
|
// Two card payments go to the same account: still one tender.
|
|
expect(unsupportedReason(sale({
|
|
payments: [{ type: 'IZETTLE_CARD', amount: 6000 }, { type: 'IZETTLE_CARD', amount: 6500 }],
|
|
}))).toBeNull()
|
|
expect(unsupportedReason(sale())).toBeNull()
|
|
})
|
|
|
|
it('does not import a refund of an unsupported sale', () => {
|
|
const rows = mapPurchaseToWebshopRows(
|
|
{ id: 'conn-1', organization_name: 'Caféet' },
|
|
'org-1',
|
|
sale({
|
|
purchaseUUID1: '33333333-3333-3333-3333-333333333333',
|
|
refund: true,
|
|
amount: -12500,
|
|
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
|
|
payments: [{ type: 'GIFTCARD', amount: -12500 }],
|
|
}),
|
|
)
|
|
expect(rows).toEqual([])
|
|
})
|
|
|
|
it('converts minor units via fromMinor', () => {
|
|
expect(fromMinor(12500)).toBe(125)
|
|
expect(fromMinor(-50)).toBe(-0.5)
|
|
})
|
|
})
|