fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage Why the problem occurred: the supplier register was written in three spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored whatever the caller sent, the AI extractor emits bare digits) while matchSupplierByIdentity compared raw strings with .eq(). The canonical rule existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey, the extractor's toOrg10) and nowhere on the path that decides a match, so every AI-extracted invoice from a hyphen-registered supplier missed the strongest key and fell to exact-name matching. Prod holds 1738 hyphenated rows against 493 bare ones. What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10 of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two other copies. The matcher scans the company's suppliers with an org_number and compares keys, the same shape as its vat_number branch, so rows written before the backfill (and self-hosted instances that never run it) match too. CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier schema store the key; the form renders it through formatOrgNumberDisplay. A backfill migration strips the formatting from existing rows, skipping migration-reset source companies. Why this and not the proposed one: the issue's third layer (CHECK plus a unique index) would fail to create on prod, which holds 94 duplicate (company_id, key) groups across 18 companies, one of them 124 rows under a single placeholder-looking number; that needs a merge decision first and is filed as #2404. Rejecting anything that is not 10 or 12 digits on write was also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL, FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is canonicalised and anything else is stored as typed. Luhn stays lenient on suppliers because two rows with the same mistyped number are one supplier and parties is Luhn-strict at promotion already. Fixes #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key Skeptic pass on the previous commit. Three refutations, all confirmed: 1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to 6012579001, another company's identity, on every write path and in the backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A Belgian BE0123456789 lost its country letters the same way. The key now strips only hyphens and spaces and unprefixes 12 digits only behind 16/18/19/20; everything else is null, stored and compared as typed. The migration carries the same rule. 2. The supplier list search, the v1 ?search= filter and the list column all used the raw stored value, so a user searching 556677-88 after the backfill found nothing. Both searches now compare without separators and the column renders XXXXXX-XXXX. 3. Storage was not canonical on every path: the CSV import and the provider migration orchestrator wrote as typed and keyed their re-sync dedup by the raw value, so a Fortnox re-sync sending 556677-8899 would have duplicated the now-bare row. Both write and key through orgNumberKey. Also: the matcher scans live suppliers only, so a register holding an archived hyphenated row next to its live replacement resolves to the live one instead of whichever id sorts first. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical CodeRabbit findings on PR #2405, all verified against the code: - The supplier list rendered through formatOrgNumber, which strips letters and would show BE0123456789 as 012345-6789; it now uses formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone. - The CSV import dedup fell back to digits-only, so BE0123456789 and FR0123456789 collided; the fallback is now the value as typed, in both the parse preview and the execute route. - The provider migration's supplier-invoice stub map was keyed by the raw provider value while the stored row was canonical, so 556677-8899 and 5566778899 on two invoices produced two stubs; the key goes through orgMapKey like the other maps. - v1 response examples show the stored 10-digit form; the request example keeps the hyphenated input. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * docs(api-skill): regenerate suppliers reference for the canonical org_number example Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1658,5 +1658,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match).
|
||||
[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it.
|
||||
[2026-09-08] Invoice list gets an 'Ej skickade' view and the PDF download on an unissued document asks first (#2399): both reports came from the same hidden state, a finalized invoice with an F-number whose DB status is still 'draft'. Chosen: split the status in the UI (lib/invoices/invoice-list-tabs.ts, one predicate for rows, counts and sections) and gate the download with a soft dialog whose primary action is the existing manual mark-sent (and book) path, then download. Rejected: a real 'issued' status in the DB (touches MCP, v1 API, reports and SIE for a distinction invoice_number already carries); removing the UTKAST stamp from numbered drafts (an unbooked invoice is not issued, the stamp is right, the flow around it was wrong); naming the tab 'Godkända' as the user asked (there is no attest step, so the label would promise one; ?status=godkanda aliases to the view).
|
||||
[2026-09-08] Supplier org_number identity (#2391): one lenient 10-digit key (orgNumberKey in lib/invariants/org-number.ts, digits only, last 10 of 12, no Luhn) now drives the exact matcher, the extractor's self-invoice guard, the web/v1 schemas and the MCP create tool, plus a backfill that strips formatting from existing rows. Luhn stays lenient on suppliers: two rows with the same mistyped number are one supplier, and parties is Luhn-strict at promotion already. Declined: the unique index on (company_id, org_number) in the same PR, because prod holds 94 duplicate groups under the canonical key (18 companies, one group of 124 rows that looks like a placeholder) that need a merge decision first; follow-up issue instead. Declined: rejecting non-Swedish shapes on write, because 68 prod rows hold foreign registration numbers in the column and eu/non-eu suppliers would become unwritable. parties is the end state for counterparty identity: no further constraints go on suppliers beyond this.
|
||||
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
|
||||
[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted.
|
||||
[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)).
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Lock, Truck } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatOrgNumberDisplay, stripOrgNumberFormatting } from '@/lib/invariants/org-number'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { SuggestionsAttn } from '@/components/parties/SuggestionsAttn'
|
||||
@@ -125,10 +126,15 @@ export default function SuppliersPage() {
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
// org_number is stored as 10 digits and shown as XXXXXX-XXXX, so the search
|
||||
// compares without separators: '556677-88' finds '5566778899'.
|
||||
const orgSearchTerm = stripOrgNumberFormatting(searchTerm)
|
||||
const filteredSuppliers = suppliers.filter((s) =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.org_number?.includes(searchTerm)
|
||||
(orgSearchTerm !== '' && s.org_number
|
||||
? stripOrgNumberFormatting(s.org_number).includes(orgSearchTerm)
|
||||
: false)
|
||||
)
|
||||
const visibleSuppliers = filteredSuppliers.slice(0, visibleCount)
|
||||
|
||||
@@ -248,7 +254,7 @@ export default function SuppliersPage() {
|
||||
{supplier.email || ''}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap tabular-nums text-muted-foreground lg:table-cell')}>
|
||||
{supplier.org_number || ''}
|
||||
{formatOrgNumberDisplay(supplier.org_number)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,16 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SupplierImportExecuteSchema } from '@/lib/api/schemas'
|
||||
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
|
||||
import { normalizeEmail } from '@/lib/import/shared/column-utils'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
|
||||
/**
|
||||
* Dedup key for an org number: the Swedish 10-digit key when the value is
|
||||
* one (so a 12-digit CSV value finds the stored 10-digit row, #2391), else
|
||||
* the value as typed, so BE0123456789 and FR0123456789 stay two suppliers.
|
||||
*/
|
||||
const orgDedupKey = (value: string | null): string | null =>
|
||||
orgNumberKey(value) ?? (value?.trim() || null)
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -51,7 +60,7 @@ export const POST = withRouteContext(
|
||||
const byOrg = new Map<string, ExistingSupplier>()
|
||||
const byEmail = new Map<string, ExistingSupplier>()
|
||||
for (const s of existing) {
|
||||
const org = normalizeOrgNumber(s.org_number)
|
||||
const org = orgDedupKey(s.org_number)
|
||||
if (org) byOrg.set(org, s)
|
||||
const email = normalizeEmail(s.email)
|
||||
if (email) byEmail.set(email, s)
|
||||
@@ -63,7 +72,7 @@ export const POST = withRouteContext(
|
||||
const errors: { row_index: number; name: string; reason: string }[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const orgKey = normalizeOrgNumber(row.org_number)
|
||||
const orgKey = orgDedupKey(row.org_number)
|
||||
const emailKey = normalizeEmail(row.email)
|
||||
const match =
|
||||
(orgKey && byOrg.get(orgKey)) ||
|
||||
@@ -79,7 +88,7 @@ export const POST = withRouteContext(
|
||||
const merged: Record<string, unknown> = {}
|
||||
if (row.name) merged.name = row.name
|
||||
if (row.supplier_type) merged.supplier_type = row.supplier_type
|
||||
if (row.org_number) merged.org_number = row.org_number
|
||||
if (row.org_number) merged.org_number = orgNumberKey(row.org_number) ?? row.org_number
|
||||
if (row.email) merged.email = row.email
|
||||
if (row.phone) merged.phone = row.phone
|
||||
if (row.address_line1) merged.address_line1 = row.address_line1
|
||||
@@ -132,7 +141,8 @@ export const POST = withRouteContext(
|
||||
postal_code: row.postal_code,
|
||||
city: row.city,
|
||||
country: row.country || 'SE',
|
||||
org_number: row.org_number,
|
||||
// Stored as the 10-digit key like every other write path (#2391).
|
||||
org_number: row.org_number ? (orgNumberKey(row.org_number) ?? row.org_number) : row.org_number,
|
||||
vat_number: row.vat_number,
|
||||
bankgiro: row.bankgiro,
|
||||
plusgiro: row.plusgiro,
|
||||
@@ -156,7 +166,7 @@ export const POST = withRouteContext(
|
||||
}
|
||||
if (data) {
|
||||
created.push(data as Supplier)
|
||||
const newOrg = normalizeOrgNumber(data.org_number)
|
||||
const newOrg = orgDedupKey(data.org_number)
|
||||
if (newOrg) byOrg.set(newOrg, data as ExistingSupplier)
|
||||
const newEmail = normalizeEmail(data.email)
|
||||
if (newEmail) byEmail.set(newEmail, data as ExistingSupplier)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseSuppliersFile } from '@/lib/import/suppliers/parser'
|
||||
import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils'
|
||||
import { normalizeEmail } from '@/lib/import/shared/column-utils'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
|
||||
// Same dedup key as the execute route (#2391): the Swedish 10-digit key when
|
||||
// the value is one, else the value as typed.
|
||||
const orgDedupKey = (value: string | null): string | null =>
|
||||
orgNumberKey(value) ?? (value?.trim() || null)
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -68,7 +74,7 @@ export const POST = withRouteContext(
|
||||
const byOrg = new Map<string, { id: string; name: string }>()
|
||||
const byEmail = new Map<string, { id: string; name: string }>()
|
||||
for (const s of existing) {
|
||||
const org = normalizeOrgNumber(s.org_number)
|
||||
const org = orgDedupKey(s.org_number)
|
||||
if (org) byOrg.set(org, { id: s.id, name: s.name })
|
||||
const email = normalizeEmail(s.email)
|
||||
if (email) byEmail.set(email, { id: s.id, name: s.name })
|
||||
@@ -76,7 +82,7 @@ export const POST = withRouteContext(
|
||||
|
||||
let duplicateCount = 0
|
||||
const annotated: AnnotatedSupplierRow[] = parsed.rows.map((r) => {
|
||||
const orgKey = normalizeOrgNumber(r.org_number)
|
||||
const orgKey = orgDedupKey(r.org_number)
|
||||
const emailKey = normalizeEmail(r.email)
|
||||
let match: AnnotatedSupplierRow['duplicate_match'] = null
|
||||
if (orgKey && byOrg.has(orgKey)) {
|
||||
|
||||
@@ -81,7 +81,7 @@ registerEndpoint({
|
||||
name: 'Office Depot AB',
|
||||
supplier_type: 'swedish_business',
|
||||
email: 'invoices@officedepot.example',
|
||||
org_number: '556677-8899',
|
||||
org_number: '5566778899',
|
||||
vat_number: 'SE556677889901',
|
||||
default_payment_terms: 30,
|
||||
default_currency: 'SEK',
|
||||
@@ -144,7 +144,14 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
const term = filters.search
|
||||
.replace(/[,()]/g, '')
|
||||
.replace(/[%_\\]/g, '\\$&')
|
||||
query = query.or(`name.ilike.%${term}%,org_number.ilike.${term}%`)
|
||||
// org_number is stored without separators (#2391); a caller searching
|
||||
// '556677-88' the way the examples show it must still find the row.
|
||||
const orgTerm = term.replace(/[\s-]/g, '')
|
||||
query = query.or(
|
||||
orgTerm === ''
|
||||
? `name.ilike.%${term}%`
|
||||
: `name.ilike.%${term}%,org_number.ilike.${orgTerm}%`,
|
||||
)
|
||||
}
|
||||
|
||||
if (decoded) {
|
||||
@@ -282,7 +289,9 @@ registerEndpoint({
|
||||
name: 'Office Depot AB',
|
||||
supplier_type: 'swedish_business',
|
||||
email: 'invoices@officedepot.example',
|
||||
org_number: '556677-8899',
|
||||
// Stored and returned as the 10-digit key; the request above shows
|
||||
// the accepted hyphenated input.
|
||||
org_number: '5566778899',
|
||||
bankgiro: '123-4567',
|
||||
default_expense_account: '5410',
|
||||
default_payment_terms: 30,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Loader2, Lock, X } from 'lucide-react'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getCountryOptions, normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
import { formatOrgNumberDisplay } from '@/lib/invariants/org-number'
|
||||
import { registryFormFill, type RegistryFormField } from '@/lib/parties/registry-form-fill'
|
||||
import { useRegistryAutofill } from '@/components/parties/use-registry-autofill'
|
||||
import { RegistryAutofillNote } from '@/components/parties/RegistryAutofillNote'
|
||||
@@ -105,7 +106,9 @@ export default function SupplierForm({
|
||||
postal_code: initialData?.postal_code || '',
|
||||
city: initialData?.city || '',
|
||||
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
|
||||
org_number: initialData?.org_number || '',
|
||||
// Stored as 10 digits, shown as XXXXXX-XXXX (the placeholder's shape);
|
||||
// the API canonicalises what the user submits.
|
||||
org_number: formatOrgNumberDisplay(initialData?.org_number),
|
||||
vat_number: initialData?.vat_number || '',
|
||||
bankgiro: initialData?.bankgiro || '',
|
||||
plusgiro: initialData?.plusgiro || '',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import { normalizeVatRateToFraction } from '@/lib/vat/vat-rate-unit'
|
||||
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import { sumLineVat, lineVatFromPercent } from '@/lib/providers/amounts'
|
||||
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
|
||||
import type {
|
||||
@@ -61,6 +62,11 @@ function getOrgNumber(party: PartyDto): string | null {
|
||||
return party.legalEntity?.companyId || null
|
||||
}
|
||||
|
||||
function canonicalSupplierOrgNumber(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
return orgNumberKey(value) ?? value
|
||||
}
|
||||
|
||||
const EU_COUNTRIES = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK']
|
||||
|
||||
/**
|
||||
@@ -633,7 +639,9 @@ export function mapSupplier(dto: SupplierDto, userId: string, companyId: string)
|
||||
email: dto.party.contact?.email || null,
|
||||
phone: dto.party.contact?.telephone || null,
|
||||
...addr,
|
||||
org_number: getOrgNumber(dto.party),
|
||||
// suppliers.org_number holds the 10-digit key (#2391); a provider sends
|
||||
// its own spelling ('556677-8899').
|
||||
org_number: canonicalSupplierOrgNumber(getOrgNumber(dto.party)),
|
||||
vat_number: dto.vatNumber || null,
|
||||
bankgiro: dto.bankGiro || null,
|
||||
plusgiro: dto.plusGiro || null,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-mess
|
||||
import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
|
||||
import { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
import { normalizeVatNumber, isValidSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import {
|
||||
fetchCompanyInfoDirect,
|
||||
fetchCustomersDirect,
|
||||
@@ -194,6 +195,14 @@ function getOrgNumberFromParty(party: PartyDto): string | null {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup key for supplier org numbers: the register stores the 10-digit key
|
||||
* (#2391) while a provider sends whatever spelling it holds ('556677-8899'),
|
||||
* so the map is keyed by orgNumberKey on both sides; a value that is not a
|
||||
* Swedish org number keys by itself, as before.
|
||||
*/
|
||||
const orgMapKey = (value: string): string => orgNumberKey(value) ?? value
|
||||
|
||||
/**
|
||||
* Log a foreign-currency document that was imported WITHOUT a SEK conversion.
|
||||
*
|
||||
@@ -484,7 +493,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
.range(from, to)
|
||||
)
|
||||
for (const row of existingSuppliers) {
|
||||
if (row.org_number) orgNumberToSupplierId.set(row.org_number, row.id)
|
||||
if (row.org_number) orgNumberToSupplierId.set(orgMapKey(row.org_number), row.id)
|
||||
if (row.name) nameToSupplierId.set(row.name, row.id)
|
||||
}
|
||||
|
||||
@@ -509,7 +518,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
// (e.g. PostNord, IKANO BANK) aren't duplicated on every re-sync.
|
||||
const orgNumber = getOrgNumberFromParty(supplier.party)
|
||||
const existingSupplierId = orgNumber
|
||||
? orgNumberToSupplierId.get(orgNumber)
|
||||
? orgNumberToSupplierId.get(orgMapKey(orgNumber))
|
||||
: supplier.party.name
|
||||
? nameToSupplierId.get(supplier.party.name)
|
||||
: undefined
|
||||
@@ -520,7 +529,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
continue
|
||||
}
|
||||
|
||||
const pendingKey = (orgNumber ?? `name:${supplier.party.name?.toLowerCase() ?? ''}`).trim()
|
||||
const pendingKey = (
|
||||
orgNumber ? orgMapKey(orgNumber) : `name:${supplier.party.name?.toLowerCase() ?? ''}`
|
||||
).trim()
|
||||
if (pendingSupplierKeys.has(pendingKey)) {
|
||||
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
||||
skipped++
|
||||
@@ -552,7 +563,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const providerId = batch[i].dto.id
|
||||
const newId = insertedRow.id as string
|
||||
supplierIdMap.set(providerId, newId)
|
||||
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
|
||||
if (insertedRow.org_number) orgNumberToSupplierId.set(orgMapKey(insertedRow.org_number as string), newId)
|
||||
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
|
||||
imported++
|
||||
}
|
||||
@@ -891,8 +902,8 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const supplierOrgNumber = getOrgNumberFromParty(inv.supplier)
|
||||
let supplierId: string | null = null
|
||||
|
||||
if (supplierOrgNumber && orgNumberToSupplierId.has(supplierOrgNumber)) {
|
||||
supplierId = orgNumberToSupplierId.get(supplierOrgNumber)!
|
||||
if (supplierOrgNumber && orgNumberToSupplierId.has(orgMapKey(supplierOrgNumber))) {
|
||||
supplierId = orgNumberToSupplierId.get(orgMapKey(supplierOrgNumber))!
|
||||
} else if (nameToSupplierId.has(inv.supplier.name)) {
|
||||
supplierId = nameToSupplierId.get(inv.supplier.name)!
|
||||
}
|
||||
@@ -909,7 +920,9 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
}
|
||||
|
||||
// Need to create a minimal supplier: dedupe the same way as customers.
|
||||
const key = (supplierOrgNumber ?? `name:${inv.supplier.name.toLowerCase()}`).trim()
|
||||
const key = (
|
||||
supplierOrgNumber ? orgMapKey(supplierOrgNumber) : `name:${inv.supplier.name.toLowerCase()}`
|
||||
).trim()
|
||||
let stub = stubByKey.get(key)
|
||||
if (!stub) {
|
||||
const supplierType = inferTypeFromParty(inv.supplier)
|
||||
@@ -923,7 +936,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
country:
|
||||
inv.supplier.postalAddress?.countryCode ||
|
||||
(supplierType === 'swedish_business' ? 'SE' : null),
|
||||
org_number: supplierOrgNumber,
|
||||
org_number: supplierOrgNumber ? orgMapKey(supplierOrgNumber) : supplierOrgNumber,
|
||||
}
|
||||
stub = { key, row: minimalSupplier, waitingInvoiceIndices: [] }
|
||||
stubByKey.set(key, stub)
|
||||
@@ -957,7 +970,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
continue
|
||||
}
|
||||
const newId = insertedRow.id as string
|
||||
if (insertedRow.org_number) orgNumberToSupplierId.set(insertedRow.org_number as string, newId)
|
||||
if (insertedRow.org_number) orgNumberToSupplierId.set(orgMapKey(insertedRow.org_number as string), newId)
|
||||
if (insertedRow.name) nameToSupplierId.set(insertedRow.name as string, newId)
|
||||
for (const idx of batch[i].waitingInvoiceIndices) {
|
||||
resolved[idx] = { ...resolved[idx], supplierId: newId }
|
||||
|
||||
@@ -17,6 +17,7 @@ import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { getAiService, readAiConfig, extractJsonObject } from '@/lib/ai'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import type { AiDocumentInput, AiImageMediaType, ExtractionSkipReason } from '@/lib/ai'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
@@ -232,21 +233,15 @@ export function promoteSingleProminentAmount(
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits only; the comparable core of an org/VAT number. */
|
||||
/** Digits only; the comparable core of a VAT number. */
|
||||
const digitsOf = (value: string | null | undefined): string => (value ?? '').replace(/\D/g, '')
|
||||
|
||||
/**
|
||||
* Canonical 10-digit form of a Swedish organisation number, or '' when the
|
||||
* input is not one. The 12-digit century-prefixed forms denote the same
|
||||
* identity: "16" for organisations, "19"/"20" for personnummer-form numbers
|
||||
* (enskild firma stores the owner's personnummer as org number). Junk that is
|
||||
* not 10 digits after trimming never matches anything.
|
||||
* input is not one: the same key the supplier matcher compares through
|
||||
* (lib/invariants/org-number.ts). Junk never matches anything.
|
||||
*/
|
||||
function toOrg10(digits: string): string {
|
||||
const trimmed =
|
||||
digits.length === 12 && /^(16|19|20)/.test(digits) ? digits.slice(2) : digits
|
||||
return trimmed.length === 10 ? trimmed : ''
|
||||
}
|
||||
const toOrg10 = (value: string | null | undefined): string => orgNumberKey(value) ?? ''
|
||||
|
||||
/**
|
||||
* Never present the receiving company as its own supplier.
|
||||
@@ -266,8 +261,8 @@ export function stripOwnCompanyAsSupplier(
|
||||
own: OwnCompanyIdentity | undefined
|
||||
): InvoiceExtractionResult {
|
||||
if (!own) return data
|
||||
const ownOrg10 = toOrg10(digitsOf(own.orgNumber))
|
||||
const extractedOrg10 = toOrg10(digitsOf(data.supplier.orgNumber))
|
||||
const ownOrg10 = toOrg10(own.orgNumber)
|
||||
const extractedOrg10 = toOrg10(data.supplier.orgNumber)
|
||||
const extractedVat = digitsOf(data.supplier.vatNumber)
|
||||
const orgHit = ownOrg10 !== '' && extractedOrg10 !== '' && extractedOrg10 === ownOrg10
|
||||
const vatHit = ownOrg10 !== '' && extractedVat === `${ownOrg10}01`
|
||||
|
||||
+25
-11
@@ -73,22 +73,34 @@ function makeMock(opts: {
|
||||
const supplierByNameResult = { data: opts.supplierByName ?? null, error: null }
|
||||
const insertResult = { data: opts.pendingInsert ?? { id: 'op-1' }, error: null }
|
||||
|
||||
// suppliers lookups distinguish by query method: org_number → .eq() chain ending in maybeSingle()
|
||||
// name → .ilike() chain ending in maybeSingle().
|
||||
// We stub by tracking the most recent .eq vs .ilike call. Simpler: return
|
||||
// org-result first, name-result second (the tool falls through).
|
||||
let supplierLookupCall = 0
|
||||
// suppliers lookups distinguish by query method: the org_number and
|
||||
// vat_number scans and the candidate search are awaited list queries
|
||||
// (served by supplierList); the exact org_number lookup for a non-Swedish
|
||||
// value is an .eq() chain ending in maybeSingle() and the name lookup an
|
||||
// .ilike() chain ending in maybeSingle(). The last filter method decides
|
||||
// which single-row result maybeSingle() serves.
|
||||
let lastFilter: 'eq' | 'ilike' = 'eq'
|
||||
const supplierChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'maybeSingle') {
|
||||
return () => {
|
||||
supplierLookupCall++
|
||||
return Promise.resolve(supplierLookupCall === 1 ? supplierByOrgResult : supplierByNameResult)
|
||||
if (prop === 'eq') {
|
||||
return (column: string) => {
|
||||
if (column !== 'company_id') lastFilter = 'eq'
|
||||
return supplierChain()
|
||||
}
|
||||
}
|
||||
if (prop === 'ilike') {
|
||||
return () => {
|
||||
lastFilter = 'ilike'
|
||||
return supplierChain()
|
||||
}
|
||||
}
|
||||
if (prop === 'maybeSingle') {
|
||||
return () =>
|
||||
Promise.resolve(lastFilter === 'ilike' ? supplierByNameResult : supplierByOrgResult)
|
||||
}
|
||||
if (prop === 'single') {
|
||||
return () =>
|
||||
Promise.resolve(
|
||||
@@ -99,7 +111,7 @@ function makeMock(opts: {
|
||||
}
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(opts.supplierList ? { data: opts.supplierList, error: null } : supplierByOrgResult)
|
||||
resolve({ data: opts.supplierList ?? [], error: null })
|
||||
}
|
||||
return () => supplierChain()
|
||||
},
|
||||
@@ -297,7 +309,9 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
|
||||
created_supplier_invoice_id: null,
|
||||
document_id: 'doc-2',
|
||||
},
|
||||
supplierByOrg: { id: 'supplier-org-lookup' },
|
||||
// The register holds the form's hyphenated spelling; the extracted
|
||||
// value is bare digits (#2391).
|
||||
supplierList: [{ id: 'supplier-org-lookup', name: 'Acme AB', org_number: '556677-8899' }],
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
const result = (await tool.execute(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* fuzzy scores never auto-resolve.
|
||||
*/
|
||||
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import { vatNumbersMatch } from '@/lib/suppliers/match-supplier'
|
||||
|
||||
export type SupplierRow = {
|
||||
@@ -58,23 +59,10 @@ export function normalizeSupplierName(raw: string): string {
|
||||
return s
|
||||
}
|
||||
|
||||
function digitsOnly(s: string): string {
|
||||
return s.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical 10-digit key for a Swedish org number. Orgnr is exactly 10
|
||||
* significant digits; enskild firma uses the owner's personnummer, which
|
||||
* appears in both 10-digit (YYMMDDXXXX) and 12-digit (YYYYMMDDXXXX) forms:
|
||||
* the last 10 digits are the same identifier. Anything else is not a Swedish
|
||||
* org number and must not fuzzy-match.
|
||||
*/
|
||||
export function orgNumberKey(raw: string): string | null {
|
||||
const d = digitsOnly(raw)
|
||||
if (d.length === 10) return d
|
||||
if (d.length === 12) return d.slice(-10)
|
||||
return null
|
||||
}
|
||||
// The key moved to lib/invariants/org-number.ts (#2391) so the exact matcher,
|
||||
// the write schemas and this fuzzy pass share one rule; re-exported for the
|
||||
// existing importers.
|
||||
export { orgNumberKey }
|
||||
|
||||
/**
|
||||
* Similarity in [0, 1]. Exact normalized match = 1; containment
|
||||
|
||||
@@ -868,6 +868,35 @@ describe('CreateSupplierSchema', () => {
|
||||
expect(result.data.email).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
// #2391: the form asks for XXXXXX-XXXX, the extractor emits bare digits;
|
||||
// storage is the 10-digit key so the matcher's exact key finds the row.
|
||||
it('stores a Swedish org number as its 10-digit key whatever the caller typed', () => {
|
||||
for (const typed of ['556677-8899', '5566778899', '556677 8899', '165566778899']) {
|
||||
const result = CreateSupplierSchema.safeParse(validSupplier({ org_number: typed }))
|
||||
expect(result.success, typed).toBe(true)
|
||||
if (result.success) expect(result.data.org_number).toBe('5566778899')
|
||||
}
|
||||
})
|
||||
|
||||
it('stores a foreign registration number or a VAT number as typed', () => {
|
||||
for (const typed of ['DK12345678', 'BE0123456789', 'SE556677889901', '556677889901']) {
|
||||
const result = CreateSupplierSchema.safeParse(
|
||||
validSupplier({ supplier_type: 'eu_business', country: 'DK', org_number: typed }),
|
||||
)
|
||||
expect(result.success, typed).toBe(true)
|
||||
if (result.success) expect(result.data.org_number).toBe(typed)
|
||||
}
|
||||
})
|
||||
|
||||
it('canonicalises org_number on update too', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ org_number: '556677-8899' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.org_number).toBe('5566778899')
|
||||
const untouched = UpdateSupplierSchema.safeParse({ name: 'Renamed AB' })
|
||||
expect(untouched.success).toBe(true)
|
||||
if (untouched.success) expect(untouched.data.org_number).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ import {
|
||||
fiscalYearSchema,
|
||||
} from '@/lib/invariants/zod'
|
||||
import { ISO_DATE_RE, ISO_DATE_MESSAGE_SV } from '@/lib/invariants/iso-date'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
@@ -1211,6 +1212,17 @@ function emptyStringAsUndefined<T extends z.ZodTypeAny>(inner: T) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* suppliers.org_number is stored as the 10-digit key (#2391): the form asks
|
||||
* for XXXXXX-XXXX and the AI extractor emits bare digits, and the matcher
|
||||
* compares through the same key, so storage is canonical whatever the caller
|
||||
* typed. Only Swedish-shaped input (10 or 12 digits once separators are
|
||||
* stripped) is rewritten; a foreign registration number or an unrecognised
|
||||
* value is stored as typed, because eu_business and non_eu_business
|
||||
* suppliers keep their home-registry number in this column.
|
||||
*/
|
||||
const supplierOrgNumber = z.string().transform((v) => orgNumberKey(v) ?? v.trim())
|
||||
|
||||
export const CreateSupplierSchema = z.object({
|
||||
name: z.string().min(1, 'Supplier name is required'),
|
||||
supplier_type: SupplierTypeSchema,
|
||||
@@ -1221,7 +1233,7 @@ export const CreateSupplierSchema = z.object({
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: CountryCodeSchema,
|
||||
org_number: z.string().optional(),
|
||||
org_number: supplierOrgNumber.optional(),
|
||||
vat_number: z.string().optional(),
|
||||
bankgiro: z.string().optional(),
|
||||
plusgiro: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
normalizeOrgNumber,
|
||||
orgNumberKey,
|
||||
isValidOrgNumber,
|
||||
isOrgNumberShaped,
|
||||
hasInvalidOrgNumberCheckDigit,
|
||||
@@ -60,6 +61,45 @@ describe('shape versus check digit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('orgNumberKey', () => {
|
||||
it('reduces every spelling of the same identity to 10 digits', () => {
|
||||
expect(orgNumberKey(AB_10)).toBe(AB_10)
|
||||
expect(orgNumberKey('556012-5790')).toBe(AB_10)
|
||||
expect(orgNumberKey('556012 5790')).toBe(AB_10)
|
||||
expect(orgNumberKey('165560125790')).toBe(AB_10)
|
||||
expect(orgNumberKey('16556012-5790')).toBe(AB_10)
|
||||
expect(orgNumberKey(' 556012 - 5790 ')).toBe(AB_10)
|
||||
expect(orgNumberKey('19800101-1231')).toBe(EF_10)
|
||||
expect(orgNumberKey('198001011231')).toBe(EF_10)
|
||||
})
|
||||
|
||||
it('keeps a VAT number typed into the org field out of the key space', () => {
|
||||
// 556012579001 is orgnr + "01"; its last 10 digits are another identity.
|
||||
expect(orgNumberKey('556012579001')).toBeNull()
|
||||
expect(orgNumberKey('SE556012579001')).toBeNull()
|
||||
expect(orgNumberKey('SE 556012-5790 01')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not strip letters: a foreign 10-digit registration is not a Swedish number', () => {
|
||||
expect(orgNumberKey('BE0123456789')).toBeNull()
|
||||
expect(orgNumberKey('SE5560125790')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not check the Luhn digit: two rows with the same mistyped number are one supplier', () => {
|
||||
expect(orgNumberKey('5560125791')).toBe('5560125791')
|
||||
expect(normalizeOrgNumber('5560125791')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for anything not org-number shaped', () => {
|
||||
expect(orgNumberKey('DK12345678')).toBeNull()
|
||||
expect(orgNumberKey('12345')).toBeNull()
|
||||
expect(orgNumberKey('12345678901')).toBeNull()
|
||||
expect(orgNumberKey('')).toBeNull()
|
||||
expect(orgNumberKey(null)).toBeNull()
|
||||
expect(orgNumberKey(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatOrgNumberDisplay', () => {
|
||||
it('renders NNNNNN-NNNN from any accepted input form', () => {
|
||||
expect(formatOrgNumberDisplay(AB_10)).toBe('556012-5790')
|
||||
|
||||
@@ -35,7 +35,7 @@ export {
|
||||
|
||||
export {
|
||||
stripOrgNumberFormatting,
|
||||
|
||||
orgNumberKey,
|
||||
isOrgNumberShaped,
|
||||
normalizeOrgNumber,
|
||||
isValidOrgNumber,
|
||||
|
||||
@@ -57,6 +57,39 @@ export function isOrgNumberShaped(raw: string | null | undefined): boolean {
|
||||
return /^\d{10}$/.test(cleaned) || /^\d{12}$/.test(cleaned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient identity key for a Swedish org number: the 10 significant digits,
|
||||
* or null when the input is not org-number shaped.
|
||||
*
|
||||
* Strips separators (hyphens, spaces), keeps 10 digits as they are and takes
|
||||
* the last 10 of a 12-digit century-prefixed form: "16" for organisations,
|
||||
* "18"/"19"/"20" for the personnummer an enskild firma uses. Only those
|
||||
* prefixes: a 12-digit value that starts with anything else is a Swedish VAT
|
||||
* number typed into the wrong field (556012579001 = orgnr + "01"), and its
|
||||
* last 10 digits are somebody else's identity. Letters are not stripped for
|
||||
* the same reason: BE0123456789 is a Belgian enterprise number, not the
|
||||
* Swedish 0123456789. Anything not shaped like a Swedish org number keys to
|
||||
* null and is stored and compared exactly as typed.
|
||||
*
|
||||
* No Luhn check on purpose: this key answers "do these two strings denote
|
||||
* the same counterparty", and two rows holding the same mistyped number are
|
||||
* still one supplier. Use {@link normalizeOrgNumber} where a number is
|
||||
* accepted into the system as valid; use this where existing values are
|
||||
* compared or canonicalised.
|
||||
*
|
||||
* `suppliers.org_number` is stored in this form: the supplier matcher, the
|
||||
* write schemas (web, v1, MCP, CSV import, provider migration) and the
|
||||
* extractor's self-invoice guard all go through it, so a hyphenated register
|
||||
* entry and a bare extracted number meet (#2391).
|
||||
*/
|
||||
export function orgNumberKey(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null
|
||||
const cleaned = stripOrgNumberFormatting(raw)
|
||||
if (/^\d{10}$/.test(cleaned)) return cleaned
|
||||
if (/^(16|18|19|20)\d{10}$/.test(cleaned)) return cleaned.slice(2)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an org number to Accounted's canonical 10-digit storage form.
|
||||
*
|
||||
|
||||
@@ -12,6 +12,29 @@ import { describe, it, expect } from 'vitest'
|
||||
import { CreateSupplierParamsSchema } from '../create-supplier'
|
||||
import { generateReverseChargeBasisLines } from '@/lib/bookkeeping/vat-entries'
|
||||
|
||||
describe('CreateSupplierParamsSchema org_number', () => {
|
||||
// #2391: the staged path stores the same 10-digit key as the dashboard.
|
||||
it('stores the 10-digit key for every accepted spelling', () => {
|
||||
for (const typed of ['556677-8899', '5566778899', '165566778899', ' 556677-8899 ']) {
|
||||
const parsed = CreateSupplierParamsSchema.parse({ name: 'Testbrand AB', org_number: typed })
|
||||
expect(parsed.org_number, typed).toBe('5566778899')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a 12-digit value that is not a century form as typed', () => {
|
||||
// A VAT number (orgnr + 01) passes the shape check but is not an identity
|
||||
// the key may rewrite.
|
||||
const parsed = CreateSupplierParamsSchema.parse({ name: 'Testbrand AB', org_number: '556677889901' })
|
||||
expect(parsed.org_number).toBe('556677889901')
|
||||
})
|
||||
|
||||
it('still rejects a value that is not a Swedish org number', () => {
|
||||
expect(() =>
|
||||
CreateSupplierParamsSchema.parse({ name: 'Testbrand AB', org_number: 'DK12345678' }),
|
||||
).toThrow(/org number/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateSupplierParamsSchema vat_number', () => {
|
||||
it('accepts an EU business supplier with no VAT number (below its national threshold)', () => {
|
||||
const parsed = CreateSupplierParamsSchema.parse({
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
import { parseVatNumber } from '@/lib/vat/vies-client'
|
||||
|
||||
const IBAN_RE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/
|
||||
@@ -54,6 +55,8 @@ function optString(inner: z.ZodTypeAny) {
|
||||
|
||||
const emailField = optString(z.string().email('Invalid email format').max(255))
|
||||
const phoneField = optString(z.string().max(50))
|
||||
// Stored as the 10-digit key the supplier matcher compares through (#2391):
|
||||
// the shape check above guarantees the key exists.
|
||||
const orgNumberField = optString(
|
||||
z
|
||||
.string()
|
||||
@@ -61,7 +64,8 @@ const orgNumberField = optString(
|
||||
.refine(
|
||||
(v) => SE_ORG_NUMBER_RE.test(v.replace(/\s/g, '')),
|
||||
'Invalid Swedish org number format (expected XXXXXX-XXXX or 12 digits)',
|
||||
),
|
||||
)
|
||||
.transform((v) => orgNumberKey(v) ?? v),
|
||||
)
|
||||
const vatNumberField = optString(
|
||||
z
|
||||
|
||||
@@ -10,19 +10,25 @@ import {
|
||||
|
||||
/**
|
||||
* Minimal suppliers-table stub. The matcher issues three shapes of query and
|
||||
* they are distinguishable by terminator: org_number and name end in
|
||||
* maybeSingle(), the vat_number scan ends in range() and is awaited directly.
|
||||
* they are distinguishable by terminator: the exact org_number lookup (only
|
||||
* for values that are not Swedish org numbers) and the name lookup end in
|
||||
* maybeSingle(); the org_number and vat_number scans go through
|
||||
* fetchAllRows, end in range(), and are told apart by the `.not(column)`
|
||||
* filter that precedes them.
|
||||
*/
|
||||
function makeSupabase(rows: {
|
||||
byOrgNumber?: { id: string } | null
|
||||
byName?: { id: string } | null
|
||||
withOrgNumber?: { id: string; org_number: string | null }[]
|
||||
withVatNumber?: { id: string; vat_number: string | null }[]
|
||||
orgScanError?: { message: string }
|
||||
vatScanError?: { message: string }
|
||||
}) {
|
||||
const calls: { column: string; value: unknown }[] = []
|
||||
|
||||
const chain = (): Record<string, unknown> => {
|
||||
const self: Record<string, unknown> = {}
|
||||
let scanColumn: string | null = null
|
||||
self.select = () => self
|
||||
self.eq = (column: string, value: unknown) => {
|
||||
if (column !== 'company_id') calls.push({ column, value })
|
||||
@@ -32,7 +38,15 @@ function makeSupabase(rows: {
|
||||
calls.push({ column: `ilike:${column}`, value })
|
||||
return self
|
||||
}
|
||||
self.not = () => self
|
||||
self.not = (column: string) => {
|
||||
scanColumn = column
|
||||
calls.push({ column: `scan:${column}`, value: null })
|
||||
return self
|
||||
}
|
||||
self.is = (column: string, value: unknown) => {
|
||||
calls.push({ column: `is:${column}`, value })
|
||||
return self
|
||||
}
|
||||
self.order = () => self
|
||||
self.limit = () => self
|
||||
self.maybeSingle = () => {
|
||||
@@ -42,13 +56,20 @@ function makeSupabase(rows: {
|
||||
}
|
||||
return Promise.resolve({ data: rows.byName ?? null, error: null })
|
||||
}
|
||||
// The vat_number scan goes through fetchAllRows, which awaits .range().
|
||||
self.range = () =>
|
||||
Promise.resolve(
|
||||
self.range = () => {
|
||||
if (scanColumn === 'org_number') {
|
||||
return Promise.resolve(
|
||||
rows.orgScanError
|
||||
? { data: null, error: rows.orgScanError }
|
||||
: { data: rows.withOrgNumber ?? [], error: null },
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
rows.vatScanError
|
||||
? { data: null, error: rows.vatScanError }
|
||||
: { data: rows.withVatNumber ?? [], error: null },
|
||||
)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -130,7 +151,7 @@ describe('matchSupplierByIdentity', () => {
|
||||
|
||||
it('prefers org_number over everything else', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
byOrgNumber: { id: 'by-org' },
|
||||
withOrgNumber: [{ id: 'by-org', org_number: '5566778899' }],
|
||||
withVatNumber: [{ id: 'by-vat', vat_number: 'SE556012579001' }],
|
||||
byName: { id: 'by-name' },
|
||||
})
|
||||
@@ -142,6 +163,88 @@ describe('matchSupplierByIdentity', () => {
|
||||
expect(match).toEqual({ supplierId: 'by-org', matchedOn: 'org_number' })
|
||||
})
|
||||
|
||||
// #2391: the form stores 556677-8899, the extractor emits 5566778899.
|
||||
it('matches org_number across every spelling of the same identity', async () => {
|
||||
const register = [
|
||||
{ id: 'hyphen', org_number: '556677-8899' },
|
||||
{ id: 'other', org_number: '5560125790' },
|
||||
{ id: 'twelve', org_number: '198001011231' },
|
||||
]
|
||||
const cases: [string, string][] = [
|
||||
['5566778899', 'hyphen'],
|
||||
['556677-8899', 'hyphen'],
|
||||
['165566778899', 'hyphen'],
|
||||
['16556677-8899', 'hyphen'],
|
||||
['556677 8899', 'hyphen'],
|
||||
['800101-1231', 'twelve'],
|
||||
['8001011231', 'twelve'],
|
||||
]
|
||||
for (const [extracted, expected] of cases) {
|
||||
const { supabase, calls } = makeSupabase({ withOrgNumber: register, byName: { id: 'by-name' } })
|
||||
const match = await matchSupplierByIdentity(supabase, 'company-1', {
|
||||
orgNumber: extracted,
|
||||
name: 'A brand name that is not the registered one',
|
||||
})
|
||||
expect(match, extracted).toEqual({ supplierId: expected, matchedOn: 'org_number' })
|
||||
expect(calls.some((c) => c.column === 'ilike:name'), extracted).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('scans live suppliers only', async () => {
|
||||
const { supabase, calls } = makeSupabase({
|
||||
withOrgNumber: [{ id: 'live', org_number: '5566778899' }],
|
||||
})
|
||||
await matchSupplierByIdentity(supabase, 'company-1', { orgNumber: '556677-8899' })
|
||||
expect(calls).toContainEqual({ column: 'is:archived_at', value: null })
|
||||
})
|
||||
|
||||
it('does not treat a VAT number or a foreign number as a Swedish org number', async () => {
|
||||
// 556677889901 is orgnr + 01; its last 10 digits are somebody else.
|
||||
const { supabase, calls } = makeSupabase({
|
||||
withOrgNumber: [{ id: 'wrong', org_number: '6677889901' }],
|
||||
byOrgNumber: null,
|
||||
})
|
||||
for (const value of ['SE556677889901', '556677889901', 'BE0123456789']) {
|
||||
const match = await matchSupplierByIdentity(supabase, 'company-1', { orgNumber: value })
|
||||
expect(match, value).toBeNull()
|
||||
}
|
||||
expect(calls.some((c) => c.column === 'scan:org_number')).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches junk in the register against a real org number', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
withOrgNumber: [
|
||||
{ id: 'junk', org_number: '12345' },
|
||||
{ id: 'foreign', org_number: 'DK12345678' },
|
||||
],
|
||||
byName: null,
|
||||
})
|
||||
const match = await matchSupplierByIdentity(supabase, 'company-1', { orgNumber: '5566778899' })
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to an exact lookup for a value that is not a Swedish org number', async () => {
|
||||
const { supabase, calls } = makeSupabase({ byOrgNumber: { id: 'foreign' } })
|
||||
const match = await matchSupplierByIdentity(supabase, 'company-1', { orgNumber: 'DK12345678' })
|
||||
expect(match).toEqual({ supplierId: 'foreign', matchedOn: 'org_number' })
|
||||
expect(calls).toContainEqual({ column: 'org_number', value: 'DK12345678' })
|
||||
expect(calls.some((c) => c.column === 'scan:org_number')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls through to vat_number and name when the org_number scan fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { supabase } = makeSupabase({
|
||||
orgScanError: { message: 'connection reset' },
|
||||
withVatNumber: [{ id: 'by-vat', vat_number: 'SE556677889901' }],
|
||||
})
|
||||
const match = await matchSupplierByIdentity(supabase, 'company-1', {
|
||||
orgNumber: '5566778899',
|
||||
vatNumber: 'SE556677889901',
|
||||
})
|
||||
expect(match).toEqual({ supplierId: 'by-vat', matchedOn: 'vat_number' })
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to vat_number when there is no org number: the Adobe case', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
byOrgNumber: null,
|
||||
@@ -220,7 +323,7 @@ describe('matchSupplierByIdentity', () => {
|
||||
|
||||
describe('matchSupplierId', () => {
|
||||
it('returns just the id', async () => {
|
||||
const { supabase } = makeSupabase({ byOrgNumber: { id: 'by-org' } })
|
||||
const { supabase } = makeSupabase({ withOrgNumber: [{ id: 'by-org', org_number: '556677-8899' }] })
|
||||
await expect(
|
||||
matchSupplierId(supabase, 'company-1', { orgNumber: '5566778899' }),
|
||||
).resolves.toBe('by-org')
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { orgNumberKey } from '@/lib/invariants/org-number'
|
||||
|
||||
export type SupplierIdentity = {
|
||||
orgNumber?: string | null
|
||||
@@ -104,7 +105,40 @@ export async function matchSupplierByIdentity(
|
||||
companyId: string,
|
||||
identity: SupplierIdentity,
|
||||
): Promise<SupplierMatch | null> {
|
||||
if (identity.orgNumber) {
|
||||
// The register was written by hand in the form's XXXXXX-XXXX shape, by the
|
||||
// v1 API and MCP in whatever the caller sent, and the extractor emits bare
|
||||
// digits: comparing raw strings missed every hyphenated row (#2391). New
|
||||
// writes store the canonical key, but the comparison stays key-based so
|
||||
// rows written before the backfill, and self-hosted instances that have
|
||||
// not run it, match too. Normalising in SQL is not possible through
|
||||
// PostgREST, so the scan happens here over the suppliers that have an
|
||||
// org_number at all: a small set even for companies with thousands of
|
||||
// suppliers, and the same shape as the vat_number scan below. Archived
|
||||
// suppliers are skipped: a register that holds the same number twice (an
|
||||
// archived hyphenated row next to its live bare replacement) must resolve
|
||||
// to the live one, not to whichever id sorts first.
|
||||
const orgKey = orgNumberKey(identity.orgNumber)
|
||||
if (orgKey) {
|
||||
try {
|
||||
const rows = await fetchAllRows<{ id: string; org_number: string | null }>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('suppliers')
|
||||
.select('id, org_number')
|
||||
.eq('company_id', companyId)
|
||||
.not('org_number', 'is', null)
|
||||
.is('archived_at', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
const hit = rows.find((row) => orgNumberKey(row.org_number) === orgKey)
|
||||
if (hit) return { supplierId: hit.id, matchedOn: 'org_number' }
|
||||
} catch (error) {
|
||||
console.error('[match-supplier] org_number lookup failed:', error)
|
||||
}
|
||||
} else if (identity.orgNumber) {
|
||||
// Not a Swedish org number (a foreign registration number passed through
|
||||
// agent-supplied extracted_data): only an exact match can be trusted.
|
||||
const { data } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
@@ -115,9 +149,7 @@ export async function matchSupplierByIdentity(
|
||||
if (data) return { supplierId: data.id as string, matchedOn: 'org_number' }
|
||||
}
|
||||
|
||||
// Normalising in SQL is not possible through PostgREST, so the comparison
|
||||
// happens here over the suppliers that have a vat_number at all: a small
|
||||
// set even for companies with thousands of suppliers.
|
||||
// Same shape as the org_number scan: compare canonical keys in memory.
|
||||
if (vatNumberKey(identity.vatNumber)) {
|
||||
try {
|
||||
const rows = await fetchAllRows<{ id: string; vat_number: string | null }>(
|
||||
|
||||
@@ -631,7 +631,7 @@ Example response `200`:
|
||||
"name": "Office Depot AB",
|
||||
"supplier_type": "swedish_business",
|
||||
"email": "invoices@officedepot.example",
|
||||
"org_number": "556677-8899",
|
||||
"org_number": "5566778899",
|
||||
"vat_number": "SE556677889901",
|
||||
"default_payment_terms": 30,
|
||||
"default_currency": "SEK",
|
||||
@@ -759,7 +759,7 @@ Example response `200`:
|
||||
"name": "Office Depot AB",
|
||||
"supplier_type": "swedish_business",
|
||||
"email": "invoices@officedepot.example",
|
||||
"org_number": "556677-8899",
|
||||
"org_number": "5566778899",
|
||||
"bankgiro": "123-4567",
|
||||
"default_expense_account": "5410",
|
||||
"default_payment_terms": 30,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Store suppliers.org_number in its canonical 10-digit form (#2391).
|
||||
--
|
||||
-- The supplier form asked for XXXXXX-XXXX, the v1 API and the MCP tool stored
|
||||
-- whatever the caller sent, and the AI extractor emits bare digits, so the
|
||||
-- register held one identity in three spellings and the exact matcher missed
|
||||
-- most of them. Every write path now canonicalises to the 10 significant
|
||||
-- digits (lib/invariants/org-number.ts, orgNumberKey: digits only, 10 kept
|
||||
-- as-is, the last 10 of a 12-digit century form); this backfill brings the
|
||||
-- rows written before that to the same form.
|
||||
--
|
||||
-- Scope:
|
||||
-- * only rows that are a Swedish org number once separators are removed:
|
||||
-- 10 digits, or 12 digits behind a century prefix (16 for organisations,
|
||||
-- 18/19/20 for a personnummer). A 12-digit value behind any other prefix
|
||||
-- is a VAT number typed into the wrong field (556012579001 = orgnr + 01)
|
||||
-- whose last 10 digits belong to somebody else; a value with letters is
|
||||
-- a foreign registration number (BE0123456789). Both stay exactly as
|
||||
-- typed, as does anything else the rule does not recognise;
|
||||
-- * companies archived by a migration reset are immutable and skipped
|
||||
-- (company_migration_resets), the same rule as 20260904010000;
|
||||
-- * idempotent: a second run matches no row.
|
||||
--
|
||||
-- suppliers_link_party fires on UPDATE OF org_number. normalize_org_number
|
||||
-- yields the same value for both spellings, so a linked row keeps its party;
|
||||
-- a row that never got one is linked now, as any edit would do.
|
||||
--
|
||||
-- No unique index on (company_id, org_number) yet: prod holds duplicate
|
||||
-- pairs under the canonical key that need a merge decision first. Adding the
|
||||
-- index is a follow-up; the matcher does not depend on it.
|
||||
|
||||
UPDATE public.suppliers s
|
||||
SET org_number = right(regexp_replace(s.org_number, '[[:space:]-]', '', 'g'), 10)
|
||||
WHERE s.org_number IS NOT NULL
|
||||
AND regexp_replace(s.org_number, '[[:space:]-]', '', 'g')
|
||||
~ '^([0-9]{10}|(16|18|19|20)[0-9]{10})$'
|
||||
AND s.org_number <> right(regexp_replace(s.org_number, '[[:space:]-]', '', 'g'), 10)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets r
|
||||
WHERE r.source_company_id = s.company_id
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for 20260908094409_suppliers_org_number_canonical.sql
|
||||
* (#2391): the backfill that brings suppliers.org_number to the 10-digit key.
|
||||
*
|
||||
* - hyphenated, spaced and 12-digit Swedish numbers become 10 digits
|
||||
* - a foreign registration number and junk stay exactly as typed
|
||||
* - a row that is already canonical is untouched
|
||||
* - a migration-reset source company is skipped
|
||||
* - the party link survives (suppliers_link_party fires on the update)
|
||||
* - idempotent: a second run changes nothing
|
||||
*/
|
||||
|
||||
// Run the real migration SQL so the test exercises exactly what ships.
|
||||
const BACKFILL_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260908094409_suppliers_org_number_canonical.sql'),
|
||||
'utf8',
|
||||
)
|
||||
async function runBackfill(): Promise<void> {
|
||||
await getPool().query(BACKFILL_SQL)
|
||||
}
|
||||
|
||||
async function insertSupplier(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
orgNumber: string | null
|
||||
name?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers (id, user_id, company_id, name, org_number)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, params.userId, params.companyId, params.name ?? 'Testleverantör AB', params.orgNumber],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function readSupplier(id: string): Promise<{ org_number: string | null; party_id: string | null }> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT org_number, party_id FROM public.suppliers WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
describe('suppliers.org_number canonical backfill', () => {
|
||||
it('rewrites Swedish-shaped numbers to the 10-digit key and leaves the rest as typed', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const cases: [string, string][] = [
|
||||
['556012-5790', '5560125790'],
|
||||
['556012 5790', '5560125790'],
|
||||
['165560125790', '5560125790'],
|
||||
['19800101-1231', '8001011231'],
|
||||
['5560125790', '5560125790'],
|
||||
['DK12345678', 'DK12345678'],
|
||||
// A VAT number in the org field: orgnr + 01, last 10 digits are
|
||||
// somebody else. Stays as typed.
|
||||
['556012579001', '556012579001'],
|
||||
['SE556012579001', 'SE556012579001'],
|
||||
// Foreign 10-digit registration: the letters are the identity.
|
||||
['BE0123456789', 'BE0123456789'],
|
||||
['12345', '12345'],
|
||||
]
|
||||
const ids: string[] = []
|
||||
for (const [typed] of cases) {
|
||||
ids.push(await insertSupplier({ userId, companyId, orgNumber: typed, name: `Leverantör ${typed}` }))
|
||||
}
|
||||
const nullRow = await insertSupplier({ userId, companyId, orgNumber: null })
|
||||
|
||||
await runBackfill()
|
||||
|
||||
for (const [i, [, expected]] of cases.entries()) {
|
||||
expect((await readSupplier(ids[i])).org_number, cases[i][0]).toBe(expected)
|
||||
}
|
||||
expect((await readSupplier(nullRow)).org_number).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the party link on a rewritten row', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertSupplier({ userId, companyId, orgNumber: '556012-5790' })
|
||||
const before = await readSupplier(id)
|
||||
expect(before.party_id).not.toBeNull()
|
||||
|
||||
await runBackfill()
|
||||
|
||||
const after = await readSupplier(id)
|
||||
expect(after.org_number).toBe('5560125790')
|
||||
expect(after.party_id).toBe(before.party_id)
|
||||
})
|
||||
|
||||
it('skips a migration-reset source company', async () => {
|
||||
const source = await seedCompany()
|
||||
const replacement = await seedCompany()
|
||||
const id = await insertSupplier({
|
||||
userId: source.userId,
|
||||
companyId: source.companyId,
|
||||
orgNumber: '556012-5790',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_migration_resets
|
||||
(source_company_id, replacement_company_id, actor_id, reason,
|
||||
confirmation_snapshot, source_counts)
|
||||
VALUES ($1, $2, $3, 'pg test: archived by a migration reset', '{}'::jsonb, '{}'::jsonb)`,
|
||||
[source.companyId, replacement.companyId, source.userId],
|
||||
)
|
||||
|
||||
await runBackfill()
|
||||
|
||||
expect((await readSupplier(id)).org_number).toBe('556012-5790')
|
||||
})
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertSupplier({ userId, companyId, orgNumber: '556012-5790' })
|
||||
await runBackfill()
|
||||
const { rows: first } = await getPool().query(
|
||||
`SELECT org_number, updated_at FROM public.suppliers WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
await runBackfill()
|
||||
const { rows: second } = await getPool().query(
|
||||
`SELECT org_number, updated_at FROM public.suppliers WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(second[0]).toEqual(first[0])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user