Skip to content
Merged
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
58 changes: 53 additions & 5 deletions backend/spec/services/payments/payment-allocation.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { Order, OrderService, Payment, PaymentService, RequestContext, User } from '@vendure/core';
import { In } from 'typeorm';
import { PaymentAllocationService } from '../../../src/services/payments/payment-allocation.service';
import { FinancialService } from '../../../src/services/financial/financial.service';
import { OpenSessionService } from '../../../src/services/financial/open-session.service';
Expand Down Expand Up @@ -251,8 +252,10 @@ describe('PaymentAllocationService', () => {
code: 'ORD-001',
state: 'ArrangingPayment',
customer: { id: 'cust-1' },
customFields: { cashierPendingAt: new Date('2024-02-15T10:30:00Z') },
createdByUserId: createdByUser,
customFields: {
cashierPendingAt: new Date('2024-02-15T10:30:00Z'),
createdByUserId: createdByUser,
},
} as unknown as Order;

const orderRepo: Record<string, jest.Mock> = {
Expand All @@ -278,7 +281,53 @@ describe('PaymentAllocationService', () => {
expect(result[0].createdBy).toBe(createdByUser);
expect(orderRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
relations: ['customer', 'createdByUserId'],
relations: ['customer'],
})
);
});

it('resolves createdBy from a scalar user ID', async () => {
const createdByUser = { id: 'user-2', identifier: 'salesperson@example.com' } as User;
const pendingOrder = {
id: 'order-2',
code: 'ORD-002',
state: 'ArrangingPayment',
customer: { id: 'cust-2' },
customFields: {
cashierPendingAt: new Date('2024-02-15T10:30:00Z'),
createdByUserId: createdByUser.id,
},
} as unknown as Order;

const orderRepo: Record<string, jest.Mock> = {
update: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};
orderRepo.find.mockReturnValue(Promise.resolve([pendingOrder]));

const userRepo = {
find: jest.fn().mockReturnValue(Promise.resolve([createdByUser])),
};

mockConnection.getRepository.mockImplementation((_: any, entity: any) =>
entity === Order ? orderRepo : userRepo
);

mockFinancialService.getOrderPaymentStatus.mockResolvedValue({
totalOwed: 10000,
amountPaid: 0,
amountOwing: 10000,
});

const result = await service.getPendingCashierOrders(ctx);

expect(result).toHaveLength(1);
expect(result[0].order.id).toBe('order-2');
expect(result[0].createdBy).toBe(createdByUser);
expect(userRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: In([createdByUser.id]) },
})
);
});
Expand All @@ -289,8 +338,7 @@ describe('PaymentAllocationService', () => {
code: 'ORD-001',
state: 'ArrangingPayment',
customer: { id: 'cust-1' },
customFields: { cashierPendingAt: new Date() },
createdByUserId: null,
customFields: { cashierPendingAt: new Date(), createdByUserId: null },
} as unknown as Order;

const orderRepo: Record<string, jest.Mock> = {
Expand Down
29 changes: 27 additions & 2 deletions backend/src/services/payments/payment-allocation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,10 +767,28 @@ export class PaymentAllocationService {
state: In(PAYABLE_ORDER_STATES),
customFields: { cashierPendingAt: Not(IsNull()) } as any,
},
relations: ['customer', 'createdByUserId'],
relations: ['customer'],
order: { createdAt: 'ASC' }, // Oldest first
});

// Relation custom fields store the FK ID when the relation is not loaded,
// and the User entity when loaded via relations/eager loading. Batch-resolve
// the creator IDs to User entities to avoid N+1 queries.
const creatorIds = new Set<ID>();
for (const order of orders) {
const ref = order.customFields?.createdByUserId;
if (ref != null && typeof ref !== 'object') {
creatorIds.add(ref);
}
}
const users =
creatorIds.size > 0
? await this.connection.getRepository(ctx, User).find({
where: { id: In([...creatorIds]) },
})
: [];
const userById = new Map(users.map(u => [u.id.toString(), u]));

// Channel isolation + correctness: getOrderPaymentStatus reads AR by orderId within the
// request's channel, so an order parked in another channel resolves to amountOwing 0 here
// and is excluded. This also drops any order whose receivable was reversed/voided while the
Expand All @@ -780,11 +798,18 @@ export class PaymentAllocationService {
const status = await this.financialService.getOrderPaymentStatus(ctx, order.id.toString());
if (status.amountOwing > 0) {
const customFields = order.customFields ?? {};
const createdByRef = customFields.createdByUserId;
const createdBy: User | null =
createdByRef == null
? null
: typeof createdByRef === 'object'
? createdByRef
: (userById.get(createdByRef.toString()) ?? null);
pending.push({
order,
amountOwing: status.amountOwing,
pendingSince: customFields.cashierPendingAt ?? null,
createdBy: order.createdByUserId ?? null,
createdBy,
});
}
}
Expand Down
20 changes: 9 additions & 11 deletions backend/src/types/vendure-extensions.d.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { User } from '@vendure/core';
import { ID, User } from '@vendure/core';

declare module '@vendure/core' {
/**
* Scalar custom fields defined in backend/src/config/custom-fields/entity.custom-fields.ts.
* Custom fields defined in backend/src/config/custom-fields/entity.custom-fields.ts.
* Augmenting the core class lets the rest of the backend access these fields without
* resorting to type assertions.
*
* Relation custom fields hold the related entity when eager-/relation-loaded,
* or the foreign-key ID when loaded via customFields only. Typing them as
* `User | ID | null` keeps both states type-safe.
*/
interface CustomOrderFields {
createdByUserId?: User | ID | null;
lastModifiedByUserId?: User | ID | null;
reversedByUserId?: User | ID | null;
cashierPendingAt?: Date | null;
reversedAt?: Date | null;
}

/**
* Relation custom fields defined in backend/src/config/custom-fields/entity.custom-fields.ts.
* Vendure stores relation custom fields as properties on the entity itself.
*/
interface Order {
createdByUserId?: User | null;
lastModifiedByUserId?: User | null;
}
}
Loading