Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(invoices): exact wip #566

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
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
44 changes: 44 additions & 0 deletions packages/vendure-plugin-invoices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,50 @@ InvoicePlugin.init({

If you are getting `{ error: 'invalid_client' }` during startup, you might have to recreate your Xero app on https://developer.xero.com/app/manage.

### Exact Online export strategy

This plugin comes with an accounting export strategy to export invoices to Exact Online. It does require some steps to set up:

1. Create a custom field `exactRefreshToken` on a Channel. This is needed to persist the current refresh token. Without it, the first refresh token will expire, and we need user interaction to log in again.

```ts
// Add the field in your vendure-config.ts
customFields: {
Channel: [
{
name: 'exactRefreshToken',
type: 'string',
public: false,
readonly: true,
},
],
},
```

2. An administrator of the Exact account should create an app: Partners > Exact App Store > login > Manage apps > Click the "+" icon
3. As redirectUrl you can specify your Vendure admin. It doesn't really matter, since we will be manually copying the URL during setup.
4. Save the `clientId`, `clientSecret` and the `redirectUrl` somewhere, we will need it later.
5. Run the following script locally, this is only needed once:

```ts
// Start your Vendure service first, to get the `app` instance
const ctx = await app.get(RequestContextService).create({
apiType: 'admin',
channelOrToken: E2E_DEFAULT_CHANNEL_TOKEN,
});
const exact = new ExactOnlineStrategy({
channelToken: 'your-channel-token', // Or undefined if you want to use this strategy for all Channels
clientId: process.env.EXACT_CLIENT_ID!,
clientSecret: process.env.EXACT_CLIENT_SECRET!,
redirectUri: process.env.EXACT_REDIRECT_URI!,
});
await exact.setupExactAuth(ctx, app);
```

If all is well, the refresh token is saved on your channel. The plugin will keep refreshing the token for you, but keep in mind that if no invoice has been synced to Exact Online **within 30 days, the refresh token will expire**. If this happens, you need to go trough the manual auth flow again.

// TODO Watch your logs for this log message is critical `No valid refresh token for Exact Online found`

### Custom accounting strategy

You can implement your own export strategy to export invoices to your custom accounting platform. Take a look at the included `XeroUKExportStrategy` as an example.
Expand Down
1 change: 1 addition & 0 deletions packages/vendure-plugin-invoices/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@vendure-hub/vendure-hub-plugin": "^0.0.2",
"adm-zip": "0.5.9",
"catch-unknown": "^2.0.0",
"puppeteer": "23.1.0",
"tmp": "0.2.1"
},
Expand Down
1 change: 1 addition & 0 deletions packages/vendure-plugin-invoices/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './strategies/storage/s3-storage.strategy';
export * from './strategies/storage/storage-strategy';
export * from './strategies/accounting/accounting-export-strategy';
export * from './strategies/accounting/xero-uk-export-strategy';
export * from './strategies/accounting/exact-online-export-strategy';
export * from './util/file.util';
export * from './util/order-calculations';
export * from './util/default-template';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { Logger } from '@vendure/core';
import querystring from 'querystring';
import { loggerCtx } from './exact-online-export-strategy';

/**
* Thrown when Exact returns a 401, meaning:
* 1. the access token is expired, or
* 2. we are trying to get a new access token to early
*/
export class AuthenticationRequiredError extends Error {
constructor(message: string) {
super(message);
}
}

export interface TokenSet {
/**
* Valid for 10 minutes. If expired, use refresh token to get a new one
*/
access_token: string;
expires_in: string;
/**
* Valid for 30 days. After that, the user needs to login again
*/
refresh_token: string;
}

export class ExactOnlineClient {
readonly url = 'https://start.exactonline.nl/api';

constructor(
private clientId: string,
private clientSecret: string,
private redirectUri: string,
private division: number
) {}

async getCustomer(
accessToken: string,
emailAddress: string
): Promise<Customer | undefined> {
const response = await fetch(
`${this.url}/v1/${
this.division
}/crm/Accounts?$select=ID,Code,Name,Email&$filter=Email eq '${encodeURIComponent(
emailAddress
)}'`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
}
);
const result = await this.throwIfError<SearchCustomerResult>(response);
const customer = result.d.results?.[0];
if (!customer) {
return undefined;
} else if (result.d.results.length > 1) {
Logger.warn(
`Multiple customers found for ${emailAddress}. Using '${customer.ID}'`,
loggerCtx
);
}
return customer;
}

getLoginUrl() {
return `${this.url}/oauth2/auth?client_id=${
this.clientId
}&redirect_uri=${encodeURIComponent(
this.redirectUri
)}&response_type=code&force_login=0`;
}

/**
* Get access and refresh tokens using code gotten by the Exact login redirect.
* redirectCode can be found in the url: `?code=stampNL001.xxxxx` stampNL001.xxxxx is the rawCode we need here
*/
async getAccessToken(redirectCode: string): Promise<TokenSet> {
const data = querystring.stringify({
code: decodeURIComponent(redirectCode),
redirect_uri: this.redirectUri,
grant_type: 'authorization_code',
client_id: this.clientId,
client_secret: this.clientSecret,
});
const response = await fetch(`${this.url}/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
});
return await this.throwIfError<TokenSet>(response);
}

/**
* Get new access token and refresh token using the current refresh token.
*/
async renewTokens(refreshToken: string): Promise<TokenSet> {
const data = querystring.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId,
client_secret: this.clientSecret,
});
const response = await fetch(`${this.url}/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
});
const result = await this.throwIfError<TokenSet>(response);
return result;
}

/**
* Throws if the response is not ok. Returns the json body as T if ok.
*/
async throwIfError<T>(response: Response): Promise<T> {
if (response.ok) {
return (await response.json()) as T;
}
const body = await response.text();
if (response.status === 401) {
throw new AuthenticationRequiredError(`${body}`);
} else {
throw new Error(`${response.status} - ${response.statusText}: ${body}`);
}
}

async isAccessTokenValid(accessToken?: string): Promise<boolean> {
if (!accessToken) {
return false;
}
const response = await fetch(
`${this.url}/v1/current/Me?$select=AccountingDivision`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
}
);
if (response.ok) {
return true;
} else if (response.status === 401) {
return false;
} else {
throw Error(
`Error checking access token: [${
response.status
}] ${await response.text()}`
);
}
}
}

interface SearchCustomerResult {
d: {
results: Array<Customer>;
};
}

interface Customer {
ID: string;
Code: string;
Email: string;
Name: string;
}
Loading