From 386749b66208c56ac5075a3d1ab06005ff187b04 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 09:22:29 +0200 Subject: [PATCH 01/10] feat(invoices): dont re-export invoice if order changed --- packages/vendure-plugin-invoices/CHANGELOG.md | 4 ++ packages/vendure-plugin-invoices/package.json | 2 +- .../src/services/accounting.service.ts | 47 ++++++++++++++++--- .../invoices-detail-view.ts | 5 ++ .../vendure-plugin-invoices/test/e2e.spec.ts | 14 ++++++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/vendure-plugin-invoices/CHANGELOG.md b/packages/vendure-plugin-invoices/CHANGELOG.md index 744fce8b5..bf5bad2c7 100644 --- a/packages/vendure-plugin-invoices/CHANGELOG.md +++ b/packages/vendure-plugin-invoices/CHANGELOG.md @@ -1,3 +1,7 @@ +# 4.0.4 (2024-08-29) + +- Don't allow exporting to accounting when the order changed. + # 4.0.3 (2024-08-27) - Try to find Xero contact by name first, then by email address diff --git a/packages/vendure-plugin-invoices/package.json b/packages/vendure-plugin-invoices/package.json index 984f222fd..26888b432 100644 --- a/packages/vendure-plugin-invoices/package.json +++ b/packages/vendure-plugin-invoices/package.json @@ -1,6 +1,6 @@ { "name": "@vendure-hub/pinelab-invoice-plugin", - "version": "4.0.3", + "version": "4.0.4", "description": "Vendure plugin for PDF invoice generation", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-invoices/src/services/accounting.service.ts b/packages/vendure-plugin-invoices/src/services/accounting.service.ts index 6dbbd2b67..47ca57f0e 100644 --- a/packages/vendure-plugin-invoices/src/services/accounting.service.ts +++ b/packages/vendure-plugin-invoices/src/services/accounting.service.ts @@ -1,23 +1,20 @@ -import { Injectable, OnModuleInit, Inject } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { EntityRelationPaths, - ID, JobQueue, JobQueueService, Logger, Order, OrderService, - Product, RequestContext, SerializedRequestContext, TransactionalConnection, UserInputError, } from '@vendure/core'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; -import { AccountingExportStrategy } from '../strategies/accounting/accounting-export-strategy'; -import { InvoicePluginConfig } from '../invoice.plugin'; -import util from 'util'; import { InvoiceEntity } from '../entities/invoice.entity'; +import { InvoicePluginConfig } from '../invoice.plugin'; +import { AccountingExportStrategy } from '../strategies/accounting/accounting-export-strategy'; @Injectable() export class AccountingService implements OnModuleInit { @@ -120,6 +117,17 @@ export class AccountingService implements OnModuleInit { } const invoiceRepository = this.connection.getRepository(ctx, InvoiceEntity); try { + if ( + !this.orderMatchesInvoice(order, invoice) && + !invoice.isCreditInvoice + ) { + // console.log('============= NO MATCH', invoice.invoiceNumber, order.total, order.totalWithTax, order.taxSummary, invoice.orderTotals) + // Throw an error when order totals don't match to prevent re-exporting wrong data. + // Credit invoices are allowed, because they use the reversed invoice.orderTotals instead of the order data itself + throw Error( + `Order '${order.code}' has changed compared to the invoice. Can not export this invoice again!` + ); + } const reference = await strategy.exportInvoice( ctx, invoice, @@ -168,6 +176,33 @@ export class AccountingService implements OnModuleInit { ); } + /** + * Checks if the total and tax rates of the order still match the ones from the invoice. + * When they differ, it means the order changed compared to the invoice. + * + * Invoice totals are made absolute (Math.abs), because it could be about a credit invoice, + * which has the same amount but negative + */ + private orderMatchesInvoice(order: Order, invoice: InvoiceEntity): boolean { + if ( + order.total !== Math.abs(invoice.orderTotals.total) || + order.totalWithTax !== Math.abs(invoice.orderTotals.totalWithTax) + ) { + // Totals don't match anymore + return false; + } + // All order tax summaries should have a matching invoice tax summary + return order.taxSummary.every((orderSummary) => { + const matchingInvoiceSummary = invoice.orderTotals.taxSummaries.find( + (invoiceSummary) => + invoiceSummary.taxRate === orderSummary.taxRate && + Math.abs(invoiceSummary.taxBase) === orderSummary.taxBase + ); + // If no matching tax summary is found, the order doesn't match the invoice + return !!matchingInvoiceSummary; + }); + } + private async getInvoiceByNumber( ctx: RequestContext, invoiceNumber: number diff --git a/packages/vendure-plugin-invoices/src/ui/invoices-detail-view/invoices-detail-view.ts b/packages/vendure-plugin-invoices/src/ui/invoices-detail-view/invoices-detail-view.ts index 4c5180a30..d22dd30e7 100644 --- a/packages/vendure-plugin-invoices/src/ui/invoices-detail-view/invoices-detail-view.ts +++ b/packages/vendure-plugin-invoices/src/ui/invoices-detail-view/invoices-detail-view.ts @@ -7,6 +7,11 @@ export const invoiceFragment = gql` invoiceNumber isCreditInvoice downloadUrl + accountingReference { + reference + link + errorMessage + } } `; diff --git a/packages/vendure-plugin-invoices/test/e2e.spec.ts b/packages/vendure-plugin-invoices/test/e2e.spec.ts index 53738f14c..c845c7f2d 100644 --- a/packages/vendure-plugin-invoices/test/e2e.spec.ts +++ b/packages/vendure-plugin-invoices/test/e2e.spec.ts @@ -36,6 +36,7 @@ import { InvoiceCreatedEvent } from '../src/services/invoice-created-event'; import { getOrderWithInvoices } from '../src/ui/invoices-detail-view/invoices-detail-view'; import { createInvoice as createInvoiceMutation, + exportToAccounting, getConfigQuery, upsertConfigMutation, } from '../src/ui/queries.graphql'; @@ -219,6 +220,19 @@ describe('Generate with credit invoicing enabled', function () { expect(order.totalWithTax).toBe(1480); }); + it('Fails to export to accounting, because order totals dont match the invoice anymore', async () => { + await adminClient.query(exportToAccounting, { + invoiceNumber: 10001, + }); + await wait(); // Wait for async export via bob queue + const { order: result } = await adminClient.query(getOrderWithInvoices, { + id: 1, + }); + expect(result.invoices[0].accountingReference.errorMessage).toContain( + 'has changed compared to the invoice' + ); + }); + it('Creates credit and new invoice on createInvoice mutation', async () => { const result = await adminClient.query(createInvoiceMutation, { orderId: order.id, From 84ab91ba9d319e0e94e02da937622b1d97b33013 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 09:22:48 +0200 Subject: [PATCH 02/10] feat(invoices): dont re-export invoice if order changed --- .../vendure-plugin-invoices/src/services/accounting.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vendure-plugin-invoices/src/services/accounting.service.ts b/packages/vendure-plugin-invoices/src/services/accounting.service.ts index 47ca57f0e..9760ad906 100644 --- a/packages/vendure-plugin-invoices/src/services/accounting.service.ts +++ b/packages/vendure-plugin-invoices/src/services/accounting.service.ts @@ -121,7 +121,6 @@ export class AccountingService implements OnModuleInit { !this.orderMatchesInvoice(order, invoice) && !invoice.isCreditInvoice ) { - // console.log('============= NO MATCH', invoice.invoiceNumber, order.total, order.totalWithTax, order.taxSummary, invoice.orderTotals) // Throw an error when order totals don't match to prevent re-exporting wrong data. // Credit invoices are allowed, because they use the reversed invoice.orderTotals instead of the order data itself throw Error( From 5d546fff9da73e54202ae8cac0add1bd8a1d092e Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 10:07:53 +0200 Subject: [PATCH 03/10] feat(invoices): separate ecerdit export interface --- packages/vendure-plugin-invoices/CHANGELOG.md | 5 +- .../src/services/accounting.service.ts | 22 +- .../accounting/accounting-export-strategy.ts | 19 +- .../accounting/xero-uk-export-strategy.ts | 220 +++++++++--------- .../vendure-plugin-invoices/test/e2e.spec.ts | 17 +- .../test/mock-accounting-strategy.ts | 15 +- 6 files changed, 169 insertions(+), 129 deletions(-) diff --git a/packages/vendure-plugin-invoices/CHANGELOG.md b/packages/vendure-plugin-invoices/CHANGELOG.md index bf5bad2c7..6535d1539 100644 --- a/packages/vendure-plugin-invoices/CHANGELOG.md +++ b/packages/vendure-plugin-invoices/CHANGELOG.md @@ -1,6 +1,7 @@ -# 4.0.4 (2024-08-29) +# 4.1.0 (2024-08-29) -- Don't allow exporting to accounting when the order changed. +- Exporting credit invoices now have their own accounting export interface +- Don't allow accounting export when the order totals changed, to prevent mismatch between accounting export and invoice # 4.0.3 (2024-08-27) diff --git a/packages/vendure-plugin-invoices/src/services/accounting.service.ts b/packages/vendure-plugin-invoices/src/services/accounting.service.ts index 9760ad906..d6017eafa 100644 --- a/packages/vendure-plugin-invoices/src/services/accounting.service.ts +++ b/packages/vendure-plugin-invoices/src/services/accounting.service.ts @@ -14,7 +14,10 @@ import { import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; import { InvoiceEntity } from '../entities/invoice.entity'; import { InvoicePluginConfig } from '../invoice.plugin'; -import { AccountingExportStrategy } from '../strategies/accounting/accounting-export-strategy'; +import { + AccountingExportStrategy, + ExternalReference, +} from '../strategies/accounting/accounting-export-strategy'; @Injectable() export class AccountingService implements OnModuleInit { @@ -127,12 +130,17 @@ export class AccountingService implements OnModuleInit { `Order '${order.code}' has changed compared to the invoice. Can not export this invoice again!` ); } - const reference = await strategy.exportInvoice( - ctx, - invoice, - order, - invoice.isCreditInvoiceFor - ); + let reference: ExternalReference; + if (invoice.isCreditInvoice) { + reference = await strategy.exportCreditInvoice( + ctx, + invoice, + invoice.isCreditInvoiceFor!, // this is always defined when it's a creditInvoice + order + ); + } else { + reference = await strategy.exportInvoice(ctx, invoice, order); + } await invoiceRepository.update(invoice.id, { accountingReference: reference, }); diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts index 4a2d698d8..cecb18b04 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts @@ -32,10 +32,23 @@ export interface AccountingExportStrategy { exportInvoice( ctx: RequestContext, invoice: InvoiceEntity, - order: Order, + order: Order + ): Promise | ExternalReference; + + /** + * Export the given Credit Invoice to the external accounting system. + * You should use invoice.orderTotals for the credit invoice and NOT the data from the order, because that will have the new data. + * You can still use order.code and address details ion your credit invoice. + * + * This function will be executed asynchronously in via the JobQueue. + */ + exportCreditInvoice( + ctx: RequestContext, + invoice: InvoiceEntity, /** - * If the invoice is a credit invoice, this will be the original invoice that the credit invoice is for. + * The original invoice that the credit invoice is for. */ - isCreditInvoiceFor?: InvoiceEntity + isCreditInvoiceFor: InvoiceEntity, + order: Order ): Promise | ExternalReference; } diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts index f5271dcb3..e9f54c608 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts @@ -2,22 +2,22 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import { - RequestContext, createSelfRefreshingCache, - SelfRefreshingCache, - Logger, - Order, Customer, - Injector, EntityHydrator, + Injector, + Logger, + Order, + RequestContext, + SelfRefreshingCache, translateDeep, } from '@vendure/core'; +import util from 'util'; +import { InvoiceEntity } from '../../entities/invoice.entity'; import { AccountingExportStrategy, ExternalReference, } from './accounting-export-strategy'; -import { InvoiceEntity } from '../../entities/invoice.entity'; -import util from 'util'; const loggerCtx = 'XeroUKAccountingExport'; @@ -110,8 +110,7 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { async exportInvoice( ctx: RequestContext, invoice: InvoiceEntity, - order: Order, - isCreditInvoiceFor?: InvoiceEntity + order: Order ): Promise { await injector.get(EntityHydrator).hydrate(ctx, order, { relations: [ @@ -133,118 +132,115 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { order.customer, order.billingAddress?.company ); - if (!invoice.isCreditInvoice) { - return await this.createInvoice(ctx, order, invoice, contact.contactID); - } else { - return await this.createCreditNote( - ctx, - order, - invoice, - isCreditInvoiceFor?.invoiceNumber, - contact.contactID - ); - } + const reference = + this.config.getReference?.(order, invoice) || order.code; + const xeroInvoice: import('xero-node').Invoice = { + invoiceNumber: String(invoice.invoiceNumber), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + type: 'ACCREC' as any, + contact: { + contactID: contact.contactID, + }, + date: this.toDate(order.orderPlacedAt ?? order.updatedAt), + lineItems: this.getLineItems(ctx, order), + reference, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + status: 'DRAFT' as any, + url: this.config.getVendureUrl?.(order, invoice), + }; + const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; + const response = await this.xero.accountingApi.createInvoices( + this.tenantId, + { invoices: [xeroInvoice] }, + true, + undefined, + idempotencyKey + ); + const createdInvoice = response.body.invoices?.[0]; + Logger.info( + `Created invoice '${invoice.invoiceNumber}' for order '${order.code}' in Xero with ID '${createdInvoice?.invoiceID}' with a total Incl. Tax of ${createdInvoice?.total}`, + loggerCtx + ); + return { + reference: createdInvoice?.invoiceID, + link: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${createdInvoice?.invoiceID}`, + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { - const errorMessage = - JSON.parse(err)?.response?.body?.Elements?.[0]?.ValidationErrors?.[0] - ?.Message || JSON.parse(err)?.response?.body?.Message; + const errorMessage = this.getErrorMessage(err); Logger.warn( - `Failed to export to Xero for order '${order.code}': ${errorMessage}`, + `Failed to export invoice to Xero for order '${order.code}': ${errorMessage}`, loggerCtx ); throw Error(errorMessage); } } - /** - * Create normal invoice in Xero - */ - async createInvoice( - ctx: RequestContext, - order: Order, - invoice: InvoiceEntity, - contactId?: string - ): Promise { - await this.tokenCache.value(); // Always get a token before making a request - const reference = this.config.getReference?.(order, invoice) || order.code; - const xeroInvoice: import('xero-node').Invoice = { - invoiceNumber: String(invoice.invoiceNumber), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any - type: 'ACCREC' as any, - contact: { - contactID: contactId, - }, - date: this.toDate(order.orderPlacedAt ?? order.updatedAt), - lineItems: this.getLineItems(ctx, order), - reference, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any - status: 'DRAFT' as any, - url: this.config.getVendureUrl?.(order, invoice), - }; - const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; - const response = await this.xero.accountingApi.createInvoices( - this.tenantId, - { invoices: [xeroInvoice] }, - true, - undefined, - idempotencyKey - ); - const createdInvoice = response.body.invoices?.[0]; - Logger.info( - `Created invoice '${invoice.invoiceNumber}' for order '${order.code}' in Xero with ID '${createdInvoice?.invoiceID}' with a total Incl. Tax of ${createdInvoice?.total}`, - loggerCtx - ); - return { - reference: createdInvoice?.invoiceID, - link: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${createdInvoice?.invoiceID}`, - }; - } - - /** - * Credit notes are a separate entity in Xero, so we have a separate method for them - */ - async createCreditNote( + async exportCreditInvoice( ctx: RequestContext, - order: Order, invoice: InvoiceEntity, - isCreditInvoiceFor?: number, - contactId?: string + isCreditInvoiceFor: InvoiceEntity, + order: Order ): Promise { await this.tokenCache.value(); // Always get a token before making a request - const reference = - this.config.getReference?.(order, invoice, isCreditInvoiceFor) || - `Credit note for ${isCreditInvoiceFor}`; - const creditNote: import('xero-node').CreditNote = { - creditNoteNumber: `${invoice.invoiceNumber} (CN)`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type: 'ACCRECCREDIT' as any, - contact: { - contactID: contactId, - }, - date: this.toDate(order.orderPlacedAt ?? order.updatedAt), - lineItems: this.getCreditLineItems(invoice), - reference, + await injector + .get(EntityHydrator) + .hydrate(ctx, order, { relations: ['customer'] }); + if (!order.customer) { + throw Error( + `Cannot export credit invoice of order '${order.code}' to Xero without a customer` + ); + } + try { + const contact = await this.getOrCreateContact( + order.customer, + order.billingAddress?.company + ); + const reference = + this.config.getReference?.( + order, + invoice, + isCreditInvoiceFor.invoiceNumber + ) || `Credit note for ${isCreditInvoiceFor}`; + const creditNote: import('xero-node').CreditNote = { + creditNoteNumber: `${invoice.invoiceNumber} (CN)`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: 'ACCRECCREDIT' as any, + contact: { + contactID: contact.contactID, + }, + date: this.toDate(order.updatedAt), + lineItems: this.getCreditLineItems(invoice), + reference, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + status: 'DRAFT' as any, + }; + const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; + const response = await this.xero.accountingApi.createCreditNotes( + this.tenantId, + { creditNotes: [creditNote] }, + true, + undefined, + idempotencyKey + ); + const creditNoteResponse = response.body.creditNotes?.[0]; + Logger.info( + `Created credit note '${invoice.invoiceNumber}' for order '${order.code}' in Xero with ID '${creditNoteResponse?.creditNoteID}' with a total Incl. Tax of ${creditNoteResponse?.total}`, + loggerCtx + ); + return { + reference: creditNoteResponse?.creditNoteID, + link: `https://go.xero.com/AccountsReceivable/EditCreditNote.aspx?creditNoteID=${creditNoteResponse?.creditNoteID}`, + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - status: 'DRAFT' as any, - }; - const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; - const response = await this.xero.accountingApi.createCreditNotes( - this.tenantId, - { creditNotes: [creditNote] }, - true, - undefined, - idempotencyKey - ); - const creditNoteResponse = response.body.creditNotes?.[0]; - Logger.info( - `Created credit note '${invoice.invoiceNumber}' for order '${order.code}' in Xero with ID '${creditNoteResponse?.creditNoteID}' with a total Incl. Tax of ${creditNoteResponse?.total}`, - loggerCtx - ); - return { - reference: creditNoteResponse?.creditNoteID, - link: `https://go.xero.com/AccountsReceivable/EditCreditNote.aspx?creditNoteID=${creditNoteResponse?.creditNoteID}`, - }; + } catch (err: any) { + const errorMessage = this.getErrorMessage(err); + Logger.warn( + `Failed to export Credit Invoice to Xero for order '${order.code}': ${errorMessage}`, + loggerCtx + ); + throw Error(errorMessage); + } } async getOrCreateContact( @@ -327,6 +323,16 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { ); } + /** + * Get the readable error message from the Xero response + */ + private getErrorMessage(err: any): string { + return ( + JSON.parse(err)?.response?.body?.Elements?.[0]?.ValidationErrors?.[0] + ?.Message || JSON.parse(err)?.response?.body?.Message + ); + } + /** * Construct line items from the order. * Also includes shipping lines and surcharges diff --git a/packages/vendure-plugin-invoices/test/e2e.spec.ts b/packages/vendure-plugin-invoices/test/e2e.spec.ts index c845c7f2d..fea01cf63 100644 --- a/packages/vendure-plugin-invoices/test/e2e.spec.ts +++ b/packages/vendure-plugin-invoices/test/e2e.spec.ts @@ -66,6 +66,7 @@ const mockAccountingStrategy = new MockAccountingStrategy( const mockAccountingStrategySpy = { init: vi.spyOn(mockAccountingStrategy, 'init'), exportInvoice: vi.spyOn(mockAccountingStrategy, 'exportInvoice'), + exportCreditInvoice: vi.spyOn(mockAccountingStrategy, 'exportCreditInvoice'), }; beforeAll(async () => { @@ -270,8 +271,9 @@ describe('Generate with credit invoicing enabled', function () { it('Triggered accounting export strategy for credit invoice', async () => { await wait(); - const [ctx, invoice, order, isCreditInvoiceFor] = - mockAccountingStrategySpy.exportInvoice.mock.calls[1]; + console.log(mockAccountingStrategySpy.exportCreditInvoice.mock.calls); + const [ctx, invoice, isCreditInvoiceFor, order] = + mockAccountingStrategySpy.exportCreditInvoice.mock.calls[0]; expect(ctx).toBeInstanceOf(RequestContext); expect(invoice).toBeInstanceOf(InvoiceEntity); expect(isCreditInvoiceFor?.invoiceNumber).toBe(10001); @@ -279,12 +281,11 @@ describe('Generate with credit invoicing enabled', function () { }); it('Triggered accounting export strategy for new invoice after credit invoice', async () => { - const [ctx, invoice, order, isCreditInvoiceFor] = - mockAccountingStrategySpy.exportInvoice.mock.calls[2]; + const [ctx, invoice, order] = + mockAccountingStrategySpy.exportInvoice.mock.calls[1]; expect(ctx).toBeInstanceOf(RequestContext); expect(invoice).toBeInstanceOf(InvoiceEntity); expect(order).toBeInstanceOf(Order); - expect(isCreditInvoiceFor).toBe(null); }); it('Returns all invoices for order', async () => { @@ -302,15 +303,15 @@ describe('Generate with credit invoicing enabled', function () { expect(invoices[0].invoiceNumber).toBe(10003); }); - it('Exports invoice to accounting again via mutation', async () => { + it('Exports the credit invoice to accounting again via mutation', async () => { const { exportInvoiceToAccountingPlatform } = await adminClient.query(gql` mutation { exportInvoiceToAccountingPlatform(invoiceNumber: 10002) } `); await wait(); - const [ctx, invoice, order, isCreditInvoiceFor] = - mockAccountingStrategySpy.exportInvoice.mock.calls[3]; + const [ctx, invoice, isCreditInvoiceFor] = + mockAccountingStrategySpy.exportCreditInvoice.mock.calls[1]; expect(exportInvoiceToAccountingPlatform).toBe(true); expect(invoice.invoiceNumber).toBe(10002); expect(isCreditInvoiceFor?.invoiceNumber).toBe(10001); diff --git a/packages/vendure-plugin-invoices/test/mock-accounting-strategy.ts b/packages/vendure-plugin-invoices/test/mock-accounting-strategy.ts index 5e7453cfe..765413f15 100644 --- a/packages/vendure-plugin-invoices/test/mock-accounting-strategy.ts +++ b/packages/vendure-plugin-invoices/test/mock-accounting-strategy.ts @@ -15,12 +15,23 @@ export class MockAccountingStrategy implements AccountingExportStrategy { exportInvoice( ctx: RequestContext, invoice: InvoiceEntity, - order: Order, - isCreditInvoiceFor?: InvoiceEntity + order: Order ): ExternalReference { return { reference: 'mockReference', link: 'mockLink', }; } + + exportCreditInvoice( + ctx: RequestContext, + invoice: InvoiceEntity, + isCreditInvoiceFor: InvoiceEntity, + order: Order + ): ExternalReference { + return { + reference: 'mockCreditReference', + link: 'mockCreditLink', + }; + } } From d8cde67b608d21df6d0b9ebf4ccb19dc864e64c1 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:07:17 +0200 Subject: [PATCH 04/10] feat(invoices): xero fixes --- packages/vendure-plugin-invoices/CHANGELOG.md | 3 +- packages/vendure-plugin-invoices/package.json | 2 +- .../src/services/accounting.service.ts | 2 +- .../accounting/xero-uk-export-strategy.ts | 53 +++++++++++++------ .../test/dev-server.ts | 22 +++----- yarn.lock | 8 +-- 6 files changed, 53 insertions(+), 37 deletions(-) diff --git a/packages/vendure-plugin-invoices/CHANGELOG.md b/packages/vendure-plugin-invoices/CHANGELOG.md index 6535d1539..228b76d89 100644 --- a/packages/vendure-plugin-invoices/CHANGELOG.md +++ b/packages/vendure-plugin-invoices/CHANGELOG.md @@ -1,7 +1,8 @@ # 4.1.0 (2024-08-29) -- Exporting credit invoices now have their own accounting export interface +- Exporting credit invoices via accounting strategies now have their own interface method - Don't allow accounting export when the order totals changed, to prevent mismatch between accounting export and invoice +- Added Due Date to Xero exports, which is needed for invoice approval # 4.0.3 (2024-08-27) diff --git a/packages/vendure-plugin-invoices/package.json b/packages/vendure-plugin-invoices/package.json index 26888b432..e64e73223 100644 --- a/packages/vendure-plugin-invoices/package.json +++ b/packages/vendure-plugin-invoices/package.json @@ -33,6 +33,6 @@ }, "gitHead": "476f36da3aafea41fbf21c70774a30306f1d238f", "devDependencies": { - "xero-node": "^9.0.0" + "xero-node": "9.2.0" } } diff --git a/packages/vendure-plugin-invoices/src/services/accounting.service.ts b/packages/vendure-plugin-invoices/src/services/accounting.service.ts index d6017eafa..e0b197595 100644 --- a/packages/vendure-plugin-invoices/src/services/accounting.service.ts +++ b/packages/vendure-plugin-invoices/src/services/accounting.service.ts @@ -55,7 +55,7 @@ export class AccountingService implements OnModuleInit { job.data.orderCode ).catch((error: Error) => { Logger.warn( - `Failed to export invoice to accounting platform for '${job.data.orderCode}': ${error?.message}`, + `Failed to export invoice '${job.data.invoiceNumber}' to accounting platform for '${job.data.orderCode}': ${error?.message}`, loggerCtx ); throw error; diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts index e9f54c608..8b527ae07 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts @@ -33,6 +33,10 @@ interface Config { */ salesAccountCode: string; channelToken?: string; + /** + * See https://central.xero.com/s/article/Add-edit-or-delete-custom-invoice-quote-templates + */ + invoiceBrandingThemeId: string; /** * Construct a reference based on the given order object */ @@ -44,7 +48,11 @@ interface Config { /** * Construct a URL that links to the order in Vendure Admin */ - getVendureUrl?: (order: Order, invoice: InvoiceEntity) => string; + getVendureUrl?(order: Order, invoice: InvoiceEntity): string; + /** + * Get the due date for an invoice. Defaults to 30 days from now + */ + getDueDate?(ctx: RequestContext, order: Order, invoice: InvoiceEntity): Date; } interface TaxRate { @@ -134,6 +142,11 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { ); const reference = this.config.getReference?.(order, invoice) || order.code; + const oneMonthLater = new Date(); + oneMonthLater.setDate(oneMonthLater.getDate() + 30); + const dueDate = this.config.getDueDate + ? this.config.getDueDate(ctx, order, invoice) + : oneMonthLater; const xeroInvoice: import('xero-node').Invoice = { invoiceNumber: String(invoice.invoiceNumber), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any @@ -141,6 +154,8 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { contact: { contactID: contact.contactID, }, + dueDate: this.toDate(dueDate), + brandingThemeID: this.config.invoiceBrandingThemeId, date: this.toDate(order.orderPlacedAt ?? order.updatedAt), lineItems: this.getLineItems(ctx, order), reference, @@ -210,6 +225,7 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { contactID: contact.contactID, }, date: this.toDate(order.updatedAt), + brandingThemeID: this.config.invoiceBrandingThemeId, lineItems: this.getCreditLineItems(invoice), reference, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -357,20 +373,27 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { } ); // Map shipping lines - lineItems.push( - ...order.shippingLines.map((shippingLine) => { - return { - description: translateDeep( - shippingLine.shippingMethod, - ctx.channel.defaultLanguageCode - ).name, - quantity: 1, - unitAmount: this.toMoney(shippingLine.discountedPrice), - accountCode: this.config.shippingAccountCode, - taxType: this.getTaxType(shippingLine.taxRate, order.code), - }; - }) - ); + // lineItems.push( + // ...order.shippingLines.map((shippingLine) => { + // return { + // description: translateDeep( + // shippingLine.shippingMethod, + // ctx.channel.defaultLanguageCode + // ).name, + // quantity: 1, + // unitAmount: this.toMoney(shippingLine.discountedPrice), + // accountCode: this.config.shippingAccountCode, + // taxType: this.getTaxType(shippingLine.taxRate, order.code), + // }; + // }) + // ); + // FIXME TEST + lineItems.push({ + description: 'TEST', + quantity: 1, + unitAmount: this.toMoney(200), + accountCode: '0103', + }); // Map surcharges lineItems.push( ...order.surcharges.map((surcharge) => { diff --git a/packages/vendure-plugin-invoices/test/dev-server.ts b/packages/vendure-plugin-invoices/test/dev-server.ts index 26d53f27a..8adebc001 100644 --- a/packages/vendure-plugin-invoices/test/dev-server.ts +++ b/packages/vendure-plugin-invoices/test/dev-server.ts @@ -45,16 +45,8 @@ require('dotenv').config(); clientSecret: process.env.XERO_CLIENT_SECRET!, shippingAccountCode: '0103', salesAccountCode: '0102', - getReference: (order, invoice, isCreditInvoiceFor) => { - if (isCreditInvoiceFor) { - return `Credit note for ${isCreditInvoiceFor}`; - } else { - return `${order.code} | PO NUMBER | ${order.payments - .filter((p) => p.state === 'Settled') - .map((p) => p.transactionId) - .join(',')}`; - } - }, + getReference: () => + 'THIS IS A TEST INVOICE, DONT APPROVE THIS PLEASE.', getVendureUrl: (order) => `https://pinelab.studio/order/${order.code}`, }), @@ -64,11 +56,11 @@ require('dotenv').config(); AdminUiPlugin.init({ port: 3002, route: 'admin', - app: compileUiExtensions({ - outputPath: path.join(__dirname, '__admin-ui'), - extensions: [InvoicePlugin.ui], - devMode: true, - }), + // app: compileUiExtensions({ + // outputPath: path.join(__dirname, '__admin-ui'), + // extensions: [InvoicePlugin.ui], + // devMode: true, + // }), }), ], paymentOptions: { diff --git a/yarn.lock b/yarn.lock index 0eafba48f..8fa9c4c90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18042,10 +18042,10 @@ xdg-basedir@^4.0.0: resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== -xero-node@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/xero-node/-/xero-node-9.0.0.tgz#1f10e9d1f25c7f636997375c6d74f3ff09064548" - integrity sha512-+e0lVD63F/O7D+UPfbPmK5NfYlxx7Ew5+4mECQYba0+5BzW1btFei8RFyHiVlGeYGpH/WaELQKKPJCTndHTCgg== +xero-node@9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/xero-node/-/xero-node-9.2.0.tgz#6ea0979ac01c41019af1ac3451dec125d020ba5b" + integrity sha512-w5B/jntQ5SQmFdFrUuZyZPPF8opB/4I9LQjl3tic/KSgs+BjSTGh6rFFD6ce0feyHHbitZYtEIswkw4ZFp1v4w== dependencies: axios "^1.6.5" openid-client "5.6.5" From 16961e153e69cb8970c580db6a0c2a4384f8aafe Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:28:25 +0200 Subject: [PATCH 05/10] feat(invoices): xero fixes --- packages/vendure-plugin-invoices/CHANGELOG.md | 2 +- packages/vendure-plugin-invoices/package.json | 2 +- .../accounting/xero-uk-export-strategy.ts | 38 ++++++++----------- .../test/dev-server.ts | 11 ++++++ 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/vendure-plugin-invoices/CHANGELOG.md b/packages/vendure-plugin-invoices/CHANGELOG.md index 228b76d89..d142f5aeb 100644 --- a/packages/vendure-plugin-invoices/CHANGELOG.md +++ b/packages/vendure-plugin-invoices/CHANGELOG.md @@ -1,4 +1,4 @@ -# 4.1.0 (2024-08-29) +# 4.1.0 (2024-08-30) - Exporting credit invoices via accounting strategies now have their own interface method - Don't allow accounting export when the order totals changed, to prevent mismatch between accounting export and invoice diff --git a/packages/vendure-plugin-invoices/package.json b/packages/vendure-plugin-invoices/package.json index e64e73223..012c12639 100644 --- a/packages/vendure-plugin-invoices/package.json +++ b/packages/vendure-plugin-invoices/package.json @@ -1,6 +1,6 @@ { "name": "@vendure-hub/pinelab-invoice-plugin", - "version": "4.0.4", + "version": "4.1.0", "description": "Vendure plugin for PDF invoice generation", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts index 8b527ae07..af2ae1d6e 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts @@ -36,7 +36,7 @@ interface Config { /** * See https://central.xero.com/s/article/Add-edit-or-delete-custom-invoice-quote-templates */ - invoiceBrandingThemeId: string; + invoiceBrandingThemeId?: string; /** * Construct a reference based on the given order object */ @@ -127,7 +127,6 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { 'lines.productVariant', 'lines.productVariant.translations', 'shippingLines.shippingMethod', - 'payments', ], }); if (!order.customer) { @@ -373,27 +372,20 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { } ); // Map shipping lines - // lineItems.push( - // ...order.shippingLines.map((shippingLine) => { - // return { - // description: translateDeep( - // shippingLine.shippingMethod, - // ctx.channel.defaultLanguageCode - // ).name, - // quantity: 1, - // unitAmount: this.toMoney(shippingLine.discountedPrice), - // accountCode: this.config.shippingAccountCode, - // taxType: this.getTaxType(shippingLine.taxRate, order.code), - // }; - // }) - // ); - // FIXME TEST - lineItems.push({ - description: 'TEST', - quantity: 1, - unitAmount: this.toMoney(200), - accountCode: '0103', - }); + lineItems.push( + ...order.shippingLines.map((shippingLine) => { + return { + description: translateDeep( + shippingLine.shippingMethod, + ctx.channel.defaultLanguageCode + ).name, + quantity: 1, + unitAmount: this.toMoney(shippingLine.discountedPrice), + accountCode: this.config.shippingAccountCode, + taxType: this.getTaxType(shippingLine.taxRate, order.code), + }; + }) + ); // Map surcharges lineItems.push( ...order.surcharges.map((surcharge) => { diff --git a/packages/vendure-plugin-invoices/test/dev-server.ts b/packages/vendure-plugin-invoices/test/dev-server.ts index 8adebc001..ebd4bdcda 100644 --- a/packages/vendure-plugin-invoices/test/dev-server.ts +++ b/packages/vendure-plugin-invoices/test/dev-server.ts @@ -45,10 +45,21 @@ require('dotenv').config(); clientSecret: process.env.XERO_CLIENT_SECRET!, shippingAccountCode: '0103', salesAccountCode: '0102', + invoiceBrandingThemeId: '62f2bce1-32c4-4e8d-a9b1-87060fb7c791', getReference: () => 'THIS IS A TEST INVOICE, DONT APPROVE THIS PLEASE.', getVendureUrl: (order) => `https://pinelab.studio/order/${order.code}`, + getDueDate: (ctx, order, invoice) => { + const payment = order.payments.find((p) => p.state === 'Settled'); + if (payment?.method === 'purchase-order') { + const date = new Date(); + date.setDate(date.getDate() + 30); //30 days later + return date; + } else { + return new Date(); + } + }, }), ], }), From a73b22b01539b9bfe6cb2aaf8f6d2b2626945eca Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:29:49 +0200 Subject: [PATCH 06/10] feat(invoices): xero fixes --- packages/vendure-plugin-invoices/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-invoices/CHANGELOG.md b/packages/vendure-plugin-invoices/CHANGELOG.md index d142f5aeb..59fd0c79d 100644 --- a/packages/vendure-plugin-invoices/CHANGELOG.md +++ b/packages/vendure-plugin-invoices/CHANGELOG.md @@ -1,6 +1,6 @@ # 4.1.0 (2024-08-30) -- Exporting credit invoices via accounting strategies now have their own interface method +- Exporting credit invoices via accounting strategies now have their own method interface - Don't allow accounting export when the order totals changed, to prevent mismatch between accounting export and invoice - Added Due Date to Xero exports, which is needed for invoice approval From 6197a4894667be42d2b69f51516229d270c18fe2 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:31:50 +0200 Subject: [PATCH 07/10] feat(invoices): xero fixes --- .../src/services/accounting.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vendure-plugin-invoices/src/services/accounting.service.ts b/packages/vendure-plugin-invoices/src/services/accounting.service.ts index e0b197595..bde76f7bd 100644 --- a/packages/vendure-plugin-invoices/src/services/accounting.service.ts +++ b/packages/vendure-plugin-invoices/src/services/accounting.service.ts @@ -187,13 +187,13 @@ export class AccountingService implements OnModuleInit { * Checks if the total and tax rates of the order still match the ones from the invoice. * When they differ, it means the order changed compared to the invoice. * - * Invoice totals are made absolute (Math.abs), because it could be about a credit invoice, - * which has the same amount but negative + * This should not be used with credit invoices, as their totals will mostly differ from the order, + * because a new invoice is created immediately */ private orderMatchesInvoice(order: Order, invoice: InvoiceEntity): boolean { if ( - order.total !== Math.abs(invoice.orderTotals.total) || - order.totalWithTax !== Math.abs(invoice.orderTotals.totalWithTax) + order.total !== invoice.orderTotals.total || + order.totalWithTax !== invoice.orderTotals.totalWithTax ) { // Totals don't match anymore return false; @@ -203,7 +203,7 @@ export class AccountingService implements OnModuleInit { const matchingInvoiceSummary = invoice.orderTotals.taxSummaries.find( (invoiceSummary) => invoiceSummary.taxRate === orderSummary.taxRate && - Math.abs(invoiceSummary.taxBase) === orderSummary.taxBase + invoiceSummary.taxBase === orderSummary.taxBase ); // If no matching tax summary is found, the order doesn't match the invoice return !!matchingInvoiceSummary; From 1fd3214376bec3930a4feacd0244fd7cadb4f010 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:34:18 +0200 Subject: [PATCH 08/10] feat(invoices): xero fixes --- packages/vendure-plugin-invoices/test/dev-server.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vendure-plugin-invoices/test/dev-server.ts b/packages/vendure-plugin-invoices/test/dev-server.ts index ebd4bdcda..ae0117e26 100644 --- a/packages/vendure-plugin-invoices/test/dev-server.ts +++ b/packages/vendure-plugin-invoices/test/dev-server.ts @@ -67,11 +67,11 @@ require('dotenv').config(); AdminUiPlugin.init({ port: 3002, route: 'admin', - // app: compileUiExtensions({ - // outputPath: path.join(__dirname, '__admin-ui'), - // extensions: [InvoicePlugin.ui], - // devMode: true, - // }), + app: compileUiExtensions({ + outputPath: path.join(__dirname, '__admin-ui'), + extensions: [InvoicePlugin.ui], + devMode: true, + }), }), ], paymentOptions: { From 9d2a61480576dbe05fe6e92d047f2cfc62c80604 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:36:29 +0200 Subject: [PATCH 09/10] feat(invoices): docs --- .../src/strategies/accounting/accounting-export-strategy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts index cecb18b04..55989fa64 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/accounting-export-strategy.ts @@ -37,8 +37,10 @@ export interface AccountingExportStrategy { /** * Export the given Credit Invoice to the external accounting system. - * You should use invoice.orderTotals for the credit invoice and NOT the data from the order, because that will have the new data. - * You can still use order.code and address details ion your credit invoice. + * You should use invoice.orderTotals for the credit invoice and NOT the data from the order, because that + * will be the current (modified) order object, not the credit invoice. + * + * You can still use order.code and address details from the order object, just not the prices. * * This function will be executed asynchronously in via the JobQueue. */ From 15e3ffebca535ebac0b003950eba92792e4ecf52 Mon Sep 17 00:00:00 2001 From: Martijn Date: Fri, 30 Aug 2024 13:39:16 +0200 Subject: [PATCH 10/10] feat(invoices): include updated at in idemptoency key --- .../src/strategies/accounting/xero-uk-export-strategy.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts index af2ae1d6e..829e9ad00 100644 --- a/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts +++ b/packages/vendure-plugin-invoices/src/strategies/accounting/xero-uk-export-strategy.ts @@ -162,7 +162,9 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { status: 'DRAFT' as any, url: this.config.getVendureUrl?.(order, invoice), }; - const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; + const idempotencyKey = `${ctx.channel.token}-${ + invoice.invoiceNumber + }-${order.updatedAt.toISOString()}`; const response = await this.xero.accountingApi.createInvoices( this.tenantId, { invoices: [xeroInvoice] }, @@ -230,7 +232,9 @@ export class XeroUKExportStrategy implements AccountingExportStrategy { // eslint-disable-next-line @typescript-eslint/no-explicit-any status: 'DRAFT' as any, }; - const idempotencyKey = `${ctx.channel.token}-${order.code}-${invoice.invoiceNumber}`; + const idempotencyKey = `${ctx.channel.token}-${ + invoice.invoiceNumber + }-${order.updatedAt.toISOString()}`; const response = await this.xero.accountingApi.createCreditNotes( this.tenantId, { creditNotes: [creditNote] },