Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/charge-matching-queue-defer-suggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@accounter/server": patch
"@accounter/client": patch
---

Stream charge-match suggestions to the queue with `@defer`. `ChargeWithSuggestions.suggestions` is
now a lazy field resolver (backed by a per-operation DataLoader that still builds the shared
candidate pool once), so the `BY_DATE` queue resolver returns base charges without scoring them.
The client `ChargesAwaitingMatchQueue` query wraps `suggestions` in a deferred inline fragment, so
the queue list paints immediately and each charge's suggestions stream in, with a per-card
"Scoring suggestions…" state while they load. `BY_SCORE` still evaluates up front (scores are
needed to sort) and carries the suggestions on the response.
1 change: 1 addition & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const config: CodegenConfig = {
CardFinancialAccount:
'../modules/financial-accounts/types.js#IGetFinancialAccountsByOwnerIdsResult',
ChargeMatch: '../modules/charges-matcher/types.js#ChargeMatchProto',
ChargeWithSuggestions: '../modules/charges-matcher/types.js#ChargeWithSuggestionsMapper',
ChargeMetadata: '../modules/charges/types.js#IGetChargesByIdsResult',
Client: '../modules/financial-entities/types.js#IGetAllClientsResult',
ClientIntegrations: '../modules/financial-entities/types.js#IGetAllClientsResult',
Expand Down
32 changes: 25 additions & 7 deletions packages/client/src/components/charge-matching/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, type ReactElement } from 'react';
import { Check, SkipForward } from 'lucide-react';
import { Check, Loader2, SkipForward } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from 'urql';
import {
Expand Down Expand Up @@ -38,10 +38,16 @@ export const QUEUE_PAGE_SIZE = 20;
type QueueEntry =
ChargesAwaitingMatchQueueQuery['chargesAwaitingMatchQueue']['baseCharges'][number];

// `suggestions` is delivered via @defer, so it is absent until the deferred
// payload for that charge arrives.
type QueueEntrySuggestions = NonNullable<QueueEntry['suggestions']>;

export type ChargeMatchQueueItem = {
id: string;
baseCharge: ChargeMatchCardFieldsFragment;
suggestions: QueueEntry['suggestions'];
suggestions: QueueEntrySuggestions;
/** True until the deferred suggestions payload for this charge has arrived */
suggestionsPending: boolean;
};

export const ChargeMatchingReviewScreen = (): ReactElement => {
Expand Down Expand Up @@ -96,7 +102,12 @@ export const ChargeMatchingReviewScreen = (): ReactElement => {
() =>
(data?.chargesAwaitingMatchQueue.baseCharges ?? []).map(entry => {
const baseCharge = getFragmentData(ChargeMatchCardFieldsFragmentDoc, entry.baseCharge);
return { id: baseCharge.id, baseCharge, suggestions: entry.suggestions };
return {
id: baseCharge.id,
baseCharge,
suggestions: entry.suggestions ?? [],
suggestionsPending: entry.suggestions == null,
};
}),
[data],
);
Expand Down Expand Up @@ -179,7 +190,7 @@ export const ChargeMatchingReviewScreen = (): ReactElement => {
</Alert>
)}

{fetching ? (
{fetching && !data ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

With the introduction of @defer for streaming suggestions, a GraphQL error in any of the deferred payloads will populate the error object. Currently, if error is truthy, the entire main UI is unmounted and replaced with null (line 165: ) : error ? null : (). This means a single deferred scoring failure will cause the already-rendered base charges to disappear, defeating the purpose of lazy loading.

To prevent this, we should still render the main UI if data is present, even if there is an error (which will still be displayed in the Alert at the top). Consider changing line 165 to check !data instead of error:

      ) : !data ? null : (

<div className="flex gap-4">
<Skeleton className="h-96 w-72" />
<Skeleton className="h-96 flex-1" />
Expand Down Expand Up @@ -221,9 +232,16 @@ export const ChargeMatchingReviewScreen = (): ReactElement => {
) : (
<section className="rounded-lg border p-4">
<h3 className="mb-2 text-sm font-semibold text-gray-500">Suggested Match</h3>
<p className="text-sm text-gray-500">
{activeItem ? 'No suggestions for this charge.' : '—'}
</p>
{activeItem?.suggestionsPending ? (
<p className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="size-4 animate-spin" />
Scoring suggestions…
</p>
) : (
<p className="text-sm text-gray-500">
{activeItem ? 'No suggestions for this charge.' : '—'}
</p>
)}
</section>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,15 @@
baseCharge {
...ChargeMatchCardFields
}
suggestions {
chargeId
confidenceScore
charge {
...ChargeMatchCardFields
# Scored lazily on the server so the queue paints immediately; the
# suggestions stream in via @defer once evaluated.
... on ChargeWithSuggestions @defer {
suggestions {
chargeId
confidenceScore
charge {
...ChargeMatchCardFields
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,14 @@ describe('chargesAwaitingMatchQueue resolver', () => {
});

describe('BY_DATE path', () => {
it('should evaluate scores only for the requested page (limit + offset applied before scoring)', async () => {
it('should paginate the page and defer scoring (no eager evaluation)', async () => {
const charges = Array.from({ length: 10 }, (_, i) => txCharge(`charge-${i}`));
const { injector, evaluateMatchesForCharges } = createTestContext({ charges });

const result = await resolve(null, { ...baseArgs, limit: 3, offset: 4 }, { injector }, null);

expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1);
expect(
(evaluateMatchesForCharges.mock.calls[0][0] as { id: string }[]).map(c => c.id),
).toEqual(['charge-4', 'charge-5', 'charge-6']);
// Suggestions are resolved lazily by the field resolver, not up front
expect(evaluateMatchesForCharges).not.toHaveBeenCalled();
expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([
'charge-4',
'charge-5',
Expand Down Expand Up @@ -262,7 +260,7 @@ describe('chargesAwaitingMatchQueue resolver', () => {

expect(result.baseCharges).toEqual([]);
expect(result.totalCount).toBe(1);
expect(evaluateMatchesForCharges).toHaveBeenCalledWith([]);
expect(evaluateMatchesForCharges).not.toHaveBeenCalled();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,40 @@ describe('QueueMatchEvaluatorProvider', () => {
expect(result.baseCharge).toBe(baseCharge);
});
});

describe('suggestionsByChargeIdLoader', () => {
it('should coalesce concurrent loads into a single batch and sort each result', async () => {
const { provider, chargesMatcherMock } = createProvider(async chargeIds => {
return new Map(
chargeIds.map(chargeId => [
chargeId,
[
{ chargeId: `${chargeId}-low`, confidenceScore: 0.2 },
{ chargeId: `${chargeId}-high`, confidenceScore: 0.8 },
],
]),
);
});

const [first, second] = await Promise.all([
provider.suggestionsByChargeIdLoader.load('charge-1'),
provider.suggestionsByChargeIdLoader.load('charge-2'),
]);

// Single shared-pool call for both charges
expect(chargesMatcherMock.findMatchesForCharges).toHaveBeenCalledTimes(1);
expect(chargesMatcherMock.findMatchesForCharges).toHaveBeenCalledWith(['charge-1', 'charge-2']);
// Each result sorted by confidence, highest first
expect(first.map(s => s.chargeId)).toEqual(['charge-1-high', 'charge-1-low']);
expect(second.map(s => s.chargeId)).toEqual(['charge-2-high', 'charge-2-low']);
});

it('should return an empty list for a charge missing from the matcher result', async () => {
const { provider } = createProvider(async () => new Map());

const result = await provider.suggestionsByChargeIdLoader.load('charge-1');

expect(result).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* no DB mutations happen here.
*/

import DataLoader from 'dataloader';
import { Injectable, Scope } from 'graphql-modules';
import type { ChargeMatchProto } from '../types.js';
import { ChargesMatcherProvider } from './charges-matcher.provider.js';
Expand Down Expand Up @@ -57,4 +58,29 @@ export class QueueMatchEvaluatorProvider {
),
}));
}

/**
* DataLoader for lazily resolving a single charge's match suggestions.
*
* Backs the `ChargeWithSuggestions.suggestions` field resolver so the queue can
* return base charges immediately and stream suggestions in via `@defer`. All
* suggestions requested within the operation are coalesced into a single
* `findMatchesForCharges` call, so the shared candidate pool is still built once.
*/
public suggestionsByChargeIdLoader = new DataLoader<string, ChargeMatchProto[], string>(
chargeIds => this.batchLoadSuggestions(chargeIds),
{ name: 'suggestionsByChargeIdLoader' },
);

private async batchLoadSuggestions(chargeIds: readonly string[]): Promise<ChargeMatchProto[][]> {
const matchesByChargeId = await this.chargesMatcherProvider.findMatchesForCharges([
...chargeIds,
]);
return chargeIds.map(chargeId =>
// Copy before sorting so we never mutate an array owned by another provider
[...(matchesByChargeId.get(chargeId) ?? [])].sort(
(a, b) => b.confidenceScore - a.confidenceScore,
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export const chargesAwaitingMatchQueueResolver: ChargesMatcherModule.Resolvers =

if (sortBy === 'BY_SCORE') {
// Evaluate the capped window of most recent charges, sort the whole
// derived list by top confidence score, and paginate that list
// derived list by top confidence score, and paginate that list. Scores
// are required to sort, so they are computed here and carried on each
// item (the suggestions field resolver returns them directly).
const window = queue.slice(0, BY_SCORE_EVALUATION_CAP);
const evaluated = await evaluator.evaluateMatchesForCharges(window);
const topScore = (suggestions: { confidenceScore: number }[]): number =>
Expand All @@ -53,12 +55,13 @@ export const chargesAwaitingMatchQueueResolver: ChargesMatcherModule.Resolvers =
};
}

// BY_DATE (default): paginate first, then lazily evaluate scores for
// the requested page only
// BY_DATE (default): paginate first and return the base charges without
// scoring them. Suggestions are resolved lazily by the
// ChargeWithSuggestions.suggestions field resolver, so the client can
// @defer them and paint the queue immediately.
const page = queue.slice(safeOffset, safeOffset + safeLimit);
const baseCharges = await evaluator.evaluateMatchesForCharges(page);
return {
baseCharges,
baseCharges: page.map(baseCharge => ({ id: baseCharge.id, baseCharge })),
totalCount: queue.length,
};
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { QueueMatchEvaluatorProvider } from '../providers/queue-match-evaluator.provider.js';
import type { ChargesMatcherModule } from '../types.js';
import { autoMatchChargesResolver } from './auto-match-charges.resolver.js';
import { chargesAwaitingMatchQueueResolver } from './charges-awaiting-match-queue.resolver.js';
Expand All @@ -24,4 +25,12 @@ export const chargesMatcherResolvers: ChargesMatcherModule.Resolvers = {
return res;
}),
},
ChargeWithSuggestions: {
// Lazily scored so the queue can return base charges immediately and stream
// suggestions via @defer. `BY_SCORE` precomputes them (needed for the sort)
// and carries them on the parent, so we return those directly when present.
suggestions: (parent, _args, { injector }) =>
parent.suggestions ??
injector.get(QueueMatchEvaluatorProvider).suggestionsByChargeIdLoader.load(parent.id),
},
};
19 changes: 19 additions & 0 deletions packages/server/src/modules/charges-matcher/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IGetChargesByFiltersResult } from '../charges/types.js';
import type { currency, document_type, IGetAllDocumentsResult } from '../documents/types.js';
import type { IGetTransactionsByIdsResult } from '../transactions/types.js';

Expand Down Expand Up @@ -31,6 +32,24 @@ export interface ChargeMatchesResult {
matches: ChargeMatchProto[];
}

/**
* GraphQL mapper for the `ChargeWithSuggestions` type.
*
* `suggestions` is optional so the queue resolver can return base charges without
* eagerly scoring them: the `ChargeWithSuggestions.suggestions` field resolver
* computes them lazily (enabling `@defer` on the client). When the queue is sorted
* `BY_SCORE` the suggestions are precomputed (needed for the sort) and carried here
* so the field resolver can return them directly.
*/
export interface ChargeWithSuggestionsMapper {
/** UUID of the unmatched base charge */
id: string;
/** The unmatched base charge under review (enriched charge row) */
baseCharge: IGetChargesByFiltersResult;
/** Precomputed match suggestions, when already evaluated (e.g. BY_SCORE sort) */
suggestions?: ChargeMatchProto[];
}

/**
* Represents a charge that was successfully merged during auto-match
*/
Expand Down
Loading