@substrat-run/connector-fortnox 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -0
- package/dist/aggregate.d.ts +69 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +101 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/api.d.ts +154 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +276 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +260 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/dist/mock.d.ts +59 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +111 -0
- package/dist/mock.js.map +1 -0
- package/dist/sie4.d.ts +132 -0
- package/dist/sie4.d.ts.map +1 -0
- package/dist/sie4.js +265 -0
- package/dist/sie4.js.map +1 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @substrat-run/connector-fortnox
|
|
2
|
+
|
|
3
|
+
Fortnox accounting for Substrat: reads a company's bookkeeping out of Fortnox as **SIE4**, parses
|
|
4
|
+
and sums it, and lands the result into a scope through the consuming vertical's own operation. A
|
|
5
|
+
**connector** is host code — you sweep it on a scope host; it is never module code.
|
|
6
|
+
|
|
7
|
+
**Full documentation: https://substrat.net/connectors/fortnox**
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
One half, not two. `connector-scrive` has a dispatch handler *and* a poll; this connector has
|
|
12
|
+
**only the poll**, and that is a design fact rather than an omission. Nothing inside a scope
|
|
13
|
+
initiates this work: a vertical does not *ask* for last month's bookkeeping the way it asks for a
|
|
14
|
+
signature — the books simply change at Fortnox, and the platform finds out by looking.
|
|
15
|
+
|
|
16
|
+
So there is no `registerFortnoxConnector`. `sweepFortnoxLedger` is a `ConnectorSweeper`, the
|
|
17
|
+
deployment binds it into the platform sweeper exactly as it binds Scrive's, and that is the whole
|
|
18
|
+
trigger surface.
|
|
19
|
+
|
|
20
|
+
Each pass, per bound scope: mint a token → list financial years → pick the one that **overlaps**
|
|
21
|
+
the period → download SIE4 → hash it → parse, sum per (account, cost centre, month) → land it in
|
|
22
|
+
pages through the vertical's operation, as the connection itself ([#97](https://github.com/substrat-run/substrat/issues/97)).
|
|
23
|
+
|
|
24
|
+
## The credential: client credentials, not the authorization-code flow
|
|
25
|
+
|
|
26
|
+
This is the most important thing on this page, because it is the opposite of what most Fortnox
|
|
27
|
+
integration write-ups describe.
|
|
28
|
+
|
|
29
|
+
Fortnox's usual flow is OAuth2 authorization-code: a person signs in, you store an access token
|
|
30
|
+
and a **refresh token**, and you refresh forever. Fortnox's refresh tokens are **single-use and
|
|
31
|
+
rotating** — each refresh mints a new one and kills the old — so two concurrent refreshes race,
|
|
32
|
+
one wins, the other saves an already-dead token and the connection is bricked. Every integration
|
|
33
|
+
built that way ends up carrying the same three mitigations: per-company coalescing of concurrent
|
|
34
|
+
refreshes, optimistic locking on the token write, and a distinct "reconnect Fortnox" error.
|
|
35
|
+
|
|
36
|
+
**This connector does not have that problem, because it never holds a refresh token.** Fortnox
|
|
37
|
+
supports `grant_type=client_credentials` against a *service* consent, and a token minted that way
|
|
38
|
+
comes from three static values:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
{ clientId, clientSecret, tenantId } // tenantId = the company's numeric DatabaseNumber
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
An access token lives an hour and is minted on demand, cached for the life of one sweep pass.
|
|
45
|
+
There is no rotating state, so there is no rotation race, no coalescing map, no optimistic write,
|
|
46
|
+
and no token table. The connection's sealed secret is the whole credential.
|
|
47
|
+
|
|
48
|
+
The price is a **one-time consent round per company**, in a browser:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { fortnoxConsentUrl } from '@substrat-run/connector-fortnox';
|
|
52
|
+
|
|
53
|
+
const url = fortnoxConsentUrl({
|
|
54
|
+
clientId, redirectUri, state,
|
|
55
|
+
scopes: ['bookkeeping', 'companyinformation'],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`account_type=service` is what makes the resulting consent mintable by client credentials
|
|
60
|
+
afterwards, and `fortnoxConsentUrl` always sets it. Your callback exchanges the code once, reads
|
|
61
|
+
`GET /3/companyinformation` for `DatabaseNumber`, and that number becomes the connection's
|
|
62
|
+
`tenantId`. After that the code path is never used again.
|
|
63
|
+
|
|
64
|
+
**To do that round once, locally**, from the repo root:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
pnpm fortnox:connect --client-id=<id> --client-secret=<secret>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or paste the portal pair into `connectors/fortnox/.dev.vars` (gitignored) as
|
|
71
|
+
`FORTNOX_CLIENT_ID` / `FORTNOX_CLIENT_SECRET` and run `pnpm fortnox:connect` with no flags —
|
|
72
|
+
it reads that file for defaults, so a retry needs no arguments and no secret reaches shell
|
|
73
|
+
history. It prints the `FORTNOX_TENANT_ID` line to add, which is the one value that cannot be
|
|
74
|
+
typed by hand.
|
|
75
|
+
|
|
76
|
+
The redirect URI must match one registered in the portal exactly; the default is
|
|
77
|
+
`http://localhost:8899/callback`. Whether Fortnox accepts `http://` or `localhost` is not
|
|
78
|
+
documented — if it refuses, register a tunnel's https URL, pass it as `--redirect-uri`, and point
|
|
79
|
+
the tunnel at `--listen-port` (default 8899). The callback always speaks plain HTTP; terminating
|
|
80
|
+
TLS is the tunnel's job.
|
|
81
|
+
|
|
82
|
+
It serves the callback, completes the consent, reads the `DatabaseNumber`, then **mints again
|
|
83
|
+
with `client_credentials`** to prove the service consent actually works — and prints the three
|
|
84
|
+
lines for `connectors/fortnox/.dev.vars`. That last step is the point: it is this connector's
|
|
85
|
+
whole premise, and it fails loudly here rather than silently skipping a test. `DatabaseNumber` is
|
|
86
|
+
shown nowhere in the Developer Portal, so there is no way to assemble the credential by hand.
|
|
87
|
+
|
|
88
|
+
**Scopes cannot be widened without a new consent round.** Ask for what the integration will need,
|
|
89
|
+
not what it needs today — this is the single most expensive thing to get wrong, because fixing it
|
|
90
|
+
means going back to every customer.
|
|
91
|
+
|
|
92
|
+
## Using it
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {
|
|
96
|
+
bindFortnoxScope,
|
|
97
|
+
sweepFortnoxLedger,
|
|
98
|
+
probeFortnoxSecret,
|
|
99
|
+
} from '@substrat-run/connector-fortnox';
|
|
100
|
+
|
|
101
|
+
// 1. Check the credential BEFORE storing it (#605) — connecting should not mean
|
|
102
|
+
// "write the row and find out on the next sweep".
|
|
103
|
+
const probe = await probeFortnoxSecret(secret, { fetch });
|
|
104
|
+
if (!probe.ok && probe.refused) throw new Error(probe.error!);
|
|
105
|
+
|
|
106
|
+
// 2. Open the connection with the client-credentials triple.
|
|
107
|
+
await host.admin.createConnection(actor, {
|
|
108
|
+
id, tenantId, vertical, provider: 'fortnox', label,
|
|
109
|
+
// String(): `DatabaseNumber` comes off the API as a number, and `fortnoxSecret`
|
|
110
|
+
// wants a digit-only STRING (it is a header value, never arithmetic).
|
|
111
|
+
secret: { clientId, clientSecret, tenantId: String(databaseNumber) }, // sealed by the SecretBox
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 3. Grant the connection the permission YOUR landing operation checks, then bind.
|
|
115
|
+
await host.admin.grantToConnection(actor, {
|
|
116
|
+
connectionId: id, permission: 'ledger:record', node, grantedBy: actor,
|
|
117
|
+
});
|
|
118
|
+
await bindFortnoxScope(host, {
|
|
119
|
+
connectionId: id, tenantId, scopeId, vertical,
|
|
120
|
+
operation: 'ledger/record-period', // yours
|
|
121
|
+
permission: 'ledger:record', // what it checks
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 4. Schedule the poll — YOUR deployment calls the sweep on a timer, as with Scrive.
|
|
125
|
+
const sweeper = (h, id, o) => sweepFortnoxLedger(h, id, { ...o });
|
|
126
|
+
// Node: startPlatformSweeper(host, { sweepers: { fortnox: sweeper }, intervalMs })
|
|
127
|
+
// Cloudflare: definePlatformSweeperDO (@substrat-run/adapter-cloudflare)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Why you name the operation, and the connector does not
|
|
131
|
+
|
|
132
|
+
What comes out of Fortnox is neutral accounting fact: accounts, cost centres, months,
|
|
133
|
+
debit-positive sums. What a business *means* by them — which account is `lokal_grundhyra`, which
|
|
134
|
+
sign normalizes it, which group it rolls into — is **vocabulary**, and vocabulary is the vertical's
|
|
135
|
+
layer. A connector that mapped account numbers to row keys would be a vertical wearing a
|
|
136
|
+
connector's clothes, and the second customer with a different chart of accounts would have to fork
|
|
137
|
+
it.
|
|
138
|
+
|
|
139
|
+
So the connector hands you `FortnoxLedgerPage` and stops. Your operation does the mapping.
|
|
140
|
+
|
|
141
|
+
### `bindFortnoxScope` refuses without the grant, on purpose
|
|
142
|
+
|
|
143
|
+
A sweep has no delivered event, so it has neither a scope to write to nor authority to write with.
|
|
144
|
+
Scrive gets both from the ledger row it wrote when the event arrived; this connector has no such
|
|
145
|
+
moment, so the binding is the declaration — and it is verified when it is made.
|
|
146
|
+
|
|
147
|
+
Without that check the binding is written, and the missing grant surfaces on the next sweep: in a
|
|
148
|
+
background timer nobody is watching, after a year of bookkeeping has already been fetched. That is
|
|
149
|
+
the shape of [#841](https://github.com/substrat-run/substrat/issues/841). Here it fails in the
|
|
150
|
+
operator's hands, naming the permission to grant.
|
|
151
|
+
|
|
152
|
+
It is also why `FORTNOX_CONNECTION_GRANTS` is empty rather than absent: the permission is the
|
|
153
|
+
consumer's and cannot be declared at this package's build time, so the check moved from build time
|
|
154
|
+
to bind time rather than disappearing.
|
|
155
|
+
|
|
156
|
+
## Caveats worth knowing
|
|
157
|
+
|
|
158
|
+
1. **Your deployment must schedule the poll** (step 4). The connector provides the driver; it
|
|
159
|
+
cannot hold a timer — that is a deployment concern. Without one wired, a binding is inert.
|
|
160
|
+
|
|
161
|
+
2. **The live round-trip is unverified.** Everything here is built and tested against
|
|
162
|
+
`FortnoxMock`, and `test/live.test.ts` runs the real API — mint, `companyinformation`,
|
|
163
|
+
`financialyears`, `sie/4` — but **only when `connectors/fortnox/.dev.vars` holds a complete
|
|
164
|
+
credential**, which as of this writing it does not. The Developer Portal creates up to 30
|
|
165
|
+
**test databases**, administered like ordinary companies, and one of those is the right
|
|
166
|
+
target — `pnpm fortnox:connect` (above) is what fills the file. Until that has run,
|
|
167
|
+
three specific claims rest on documentation rather than measurement: that client-credentials
|
|
168
|
+
minting works as described, that the SIE4 response really is ISO-8859-1, and that the response
|
|
169
|
+
envelopes are `FinancialYears` / `CompanyInformation`. It stays a `0.x` release for this reason.
|
|
170
|
+
|
|
171
|
+
`test/live.test.ts` reads only: the data calls are GETs, and the one POST is the token mint,
|
|
172
|
+
which creates no Fortnox record. This connector has no write path to Fortnox at all, so
|
|
173
|
+
running it against a production company cannot damage anything.
|
|
174
|
+
|
|
175
|
+
3. **SIE4 is ISO-8859-1, and nothing in the response says so.** Decoding it as UTF-8 does not
|
|
176
|
+
throw; it silently mangles every å/ä/ö in every account name and cost-centre label. That is a
|
|
177
|
+
corrupted ledger that looks like a working one. `FortnoxApi.sieFile` is the only place that
|
|
178
|
+
knows, and `FortnoxMock` serves real latin1 bytes so a regression fails in the suite rather than
|
|
179
|
+
in production.
|
|
180
|
+
|
|
181
|
+
4. **Pick the financial year by OVERLAP, never containment.** A newly-acquired company's first
|
|
182
|
+
financial year can start mid-month (24 April, say). A search for "the year containing 1 January"
|
|
183
|
+
finds nothing and the sync reports empty books for a company with a full year of bookkeeping.
|
|
184
|
+
`financialYearFor` does this correctly and is exported for callers that need to ask directly.
|
|
185
|
+
|
|
186
|
+
5. **Amounts are debit-positive, and this connector does not normalize them.** A cost account
|
|
187
|
+
carries a positive amount for a cost; a revenue account (a credit) carries a negative one. That
|
|
188
|
+
is correct double-entry, and inventing a sign here would put business meaning in a format
|
|
189
|
+
reader. Your mapping applies the sign.
|
|
190
|
+
|
|
191
|
+
6. **A sync is all-or-nothing per scope, by cursor.** The content hash is written only after every
|
|
192
|
+
page has landed, so a failure part-way leaves the cursor at the previous hash and the next sweep
|
|
193
|
+
retries the whole year — rather than resuming into a half-written ledger. The `syncId` is that
|
|
194
|
+
hash, so every page of one run shares it and a consumer can swap a whole run atomically.
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
`FortnoxMock` implements the token endpoint and the three REST reads in memory, so the whole sweep
|
|
199
|
+
runs without a Fortnox account — credential minting, latin1 decoding, year selection, paging,
|
|
200
|
+
the unchanged-hash skip, and the bind-time grant refusal.
|
|
201
|
+
|
|
202
|
+
**What a mock proves:** that our shape works. **What it cannot prove:** that our reading of
|
|
203
|
+
Fortnox's API is correct — the mock *is* our reading. Green here means *ready to check against a
|
|
204
|
+
real company*, which is caveat 2.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Money } from '@substrat-run/contracts';
|
|
2
|
+
import type { FortnoxFinancialYear } from './api.js';
|
|
3
|
+
import type { SieLedger } from './sie4.js';
|
|
4
|
+
/**
|
|
5
|
+
* The cost-centre dimension. SIE reserves dimension `1` for kostnadsställe, and
|
|
6
|
+
* Fortnox follows the standard — so a per-property or per-unit breakdown needs no
|
|
7
|
+
* extra API call and no server-side filtering, only this constant.
|
|
8
|
+
*/
|
|
9
|
+
export declare const COST_CENTRE_DIMENSION = "1";
|
|
10
|
+
/** One summed cell: an account, a cost centre, and a month. */
|
|
11
|
+
export interface LedgerBalance {
|
|
12
|
+
account: string;
|
|
13
|
+
/** The dimension-1 object code, or `null` for rows booked without one. */
|
|
14
|
+
costCentre: string | null;
|
|
15
|
+
/** `YYYY-MM`. */
|
|
16
|
+
month: string;
|
|
17
|
+
/** Debit-positive, exactly as SIE stated it — see {@link SieLedger}. */
|
|
18
|
+
amount: Money;
|
|
19
|
+
}
|
|
20
|
+
/** What {@link summarizeLedger} produces: the sums plus the labels to render them. */
|
|
21
|
+
export interface LedgerSummary {
|
|
22
|
+
currency: string;
|
|
23
|
+
balances: LedgerBalance[];
|
|
24
|
+
/** Only the accounts that actually carry a balance, with their `#KONTO` names. */
|
|
25
|
+
accounts: {
|
|
26
|
+
number: string;
|
|
27
|
+
name: string;
|
|
28
|
+
}[];
|
|
29
|
+
/** Only the dimension-1 objects that actually appear, with their `#OBJEKT` names. */
|
|
30
|
+
costCentres: {
|
|
31
|
+
code: string;
|
|
32
|
+
name: string;
|
|
33
|
+
}[];
|
|
34
|
+
/** Rows skipped because their date fell outside the requested window. */
|
|
35
|
+
outOfRange: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Pick the financial year to export for a given period.
|
|
39
|
+
*
|
|
40
|
+
* **Overlap, not containment** — this is the PDF's trap, and it is a real one. A
|
|
41
|
+
* newly-acquired company's first financial year can start mid-month (24 April, say),
|
|
42
|
+
* so a search for "the year containing 1 January" finds nothing and the sync reports
|
|
43
|
+
* an empty ledger for a company that has a full year of bookkeeping. Asking instead
|
|
44
|
+
* for the year that *overlaps* the period always finds it.
|
|
45
|
+
*
|
|
46
|
+
* Ties are broken toward the later year: when a period spans a year boundary, the
|
|
47
|
+
* newer year is the one still being posted to.
|
|
48
|
+
*/
|
|
49
|
+
export declare function financialYearFor(years: readonly FortnoxFinancialYear[], period: {
|
|
50
|
+
from: string;
|
|
51
|
+
to: string;
|
|
52
|
+
}): FortnoxFinancialYear | null;
|
|
53
|
+
/**
|
|
54
|
+
* Sum a parsed ledger per (account, cost centre, month).
|
|
55
|
+
*
|
|
56
|
+
* Exact decimal arithmetic throughout (`addDecimal` over micro-units), never floats:
|
|
57
|
+
* a ledger summed with `+=` on JavaScript numbers drifts by öre over a year of
|
|
58
|
+
* vouchers, and an accounting figure that is nearly right is worse than one that is
|
|
59
|
+
* obviously missing.
|
|
60
|
+
*
|
|
61
|
+
* The window is inclusive on both ends and filters on the TRANSACTION's date, which
|
|
62
|
+
* is the voucher's unless a row overrode it — so a voucher straddling a month
|
|
63
|
+
* boundary lands its rows in the months they belong to.
|
|
64
|
+
*/
|
|
65
|
+
export declare function summarizeLedger(ledger: SieLedger, window?: {
|
|
66
|
+
from: string;
|
|
67
|
+
to: string;
|
|
68
|
+
}): LedgerSummary;
|
|
69
|
+
//# sourceMappingURL=aggregate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../src/aggregate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,+DAA+D;AAC/D,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,EAAE,KAAK,CAAC;CACf;AAED,sFAAsF;AACtF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,kFAAkF;IAClF,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC7C,qFAAqF;IACrF,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,SAAS,oBAAoB,EAAE,EACtC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GACnC,oBAAoB,GAAG,IAAI,CAI7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GACpC,aAAa,CAqEf"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { addDecimal, moneyOf } from '@substrat-run/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* The cost-centre dimension. SIE reserves dimension `1` for kostnadsställe, and
|
|
4
|
+
* Fortnox follows the standard — so a per-property or per-unit breakdown needs no
|
|
5
|
+
* extra API call and no server-side filtering, only this constant.
|
|
6
|
+
*/
|
|
7
|
+
export const COST_CENTRE_DIMENSION = '1';
|
|
8
|
+
/**
|
|
9
|
+
* Pick the financial year to export for a given period.
|
|
10
|
+
*
|
|
11
|
+
* **Overlap, not containment** — this is the PDF's trap, and it is a real one. A
|
|
12
|
+
* newly-acquired company's first financial year can start mid-month (24 April, say),
|
|
13
|
+
* so a search for "the year containing 1 January" finds nothing and the sync reports
|
|
14
|
+
* an empty ledger for a company that has a full year of bookkeeping. Asking instead
|
|
15
|
+
* for the year that *overlaps* the period always finds it.
|
|
16
|
+
*
|
|
17
|
+
* Ties are broken toward the later year: when a period spans a year boundary, the
|
|
18
|
+
* newer year is the one still being posted to.
|
|
19
|
+
*/
|
|
20
|
+
export function financialYearFor(years, period) {
|
|
21
|
+
const overlapping = years.filter((y) => y.FromDate <= period.to && y.ToDate >= period.from);
|
|
22
|
+
if (overlapping.length === 0)
|
|
23
|
+
return null;
|
|
24
|
+
return overlapping.sort((a, b) => (a.FromDate < b.FromDate ? 1 : -1))[0];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sum a parsed ledger per (account, cost centre, month).
|
|
28
|
+
*
|
|
29
|
+
* Exact decimal arithmetic throughout (`addDecimal` over micro-units), never floats:
|
|
30
|
+
* a ledger summed with `+=` on JavaScript numbers drifts by öre over a year of
|
|
31
|
+
* vouchers, and an accounting figure that is nearly right is worse than one that is
|
|
32
|
+
* obviously missing.
|
|
33
|
+
*
|
|
34
|
+
* The window is inclusive on both ends and filters on the TRANSACTION's date, which
|
|
35
|
+
* is the voucher's unless a row overrode it — so a voucher straddling a month
|
|
36
|
+
* boundary lands its rows in the months they belong to.
|
|
37
|
+
*/
|
|
38
|
+
export function summarizeLedger(ledger, window) {
|
|
39
|
+
const sums = new Map();
|
|
40
|
+
const seenAccounts = new Set();
|
|
41
|
+
const seenCentres = new Set();
|
|
42
|
+
let outOfRange = 0;
|
|
43
|
+
for (const voucher of ledger.vouchers) {
|
|
44
|
+
for (const t of voucher.transactions) {
|
|
45
|
+
if (t.account === '')
|
|
46
|
+
continue;
|
|
47
|
+
const date = t.date === '' ? voucher.date : t.date;
|
|
48
|
+
if (window && (date < window.from || date > window.to)) {
|
|
49
|
+
outOfRange += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const month = date.slice(0, 7);
|
|
53
|
+
if (month === '')
|
|
54
|
+
continue;
|
|
55
|
+
const costCentre = t.objects[COST_CENTRE_DIMENSION] ?? null;
|
|
56
|
+
// NUL as the delimiter, because SIE cannot carry one in a field. A space could:
|
|
57
|
+
// `#OBJEKT` codes are quoted free text, so `{"1" "KV 1"}` is legal, and joining on
|
|
58
|
+
// spaces lets two distinct (account, cost centre, month) triples produce one key —
|
|
59
|
+
// silently summing two unrelated balances into a single cell.
|
|
60
|
+
const key = `${t.account}\u0000${costCentre ?? ''}\u0000${month}`;
|
|
61
|
+
const existing = sums.get(key);
|
|
62
|
+
// Seeded through `addDecimal` rather than stored raw, so a cell with ONE
|
|
63
|
+
// transaction is as canonical as a cell with two. `t.amount` is whatever the file
|
|
64
|
+
// said; `moneyOf` below demands `moneyAmount`'s shape, and a >6dp value satisfies
|
|
65
|
+
// the first and not the second — so a single-row cell was the one path that could
|
|
66
|
+
// reach `moneyOf` unnormalized.
|
|
67
|
+
if (existing)
|
|
68
|
+
existing.amount = addDecimal(existing.amount, t.amount);
|
|
69
|
+
else
|
|
70
|
+
sums.set(key, { account: t.account, costCentre, month, amount: addDecimal('0', t.amount) });
|
|
71
|
+
seenAccounts.add(t.account);
|
|
72
|
+
if (costCentre !== null)
|
|
73
|
+
seenCentres.add(costCentre);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const accountNames = new Map(ledger.accounts.map((a) => [a.number, a.name]));
|
|
77
|
+
const centreNames = new Map(ledger.objects.filter((o) => o.dimension === COST_CENTRE_DIMENSION).map((o) => [o.code, o.name]));
|
|
78
|
+
// Sorted so a sync is stable: the same year exported twice produces the same pages
|
|
79
|
+
// in the same order, which is what lets a consumer diff two runs and lets a test
|
|
80
|
+
// assert on a page without sorting it first.
|
|
81
|
+
const balances = [...sums.values()]
|
|
82
|
+
.sort((a, b) => a.account.localeCompare(b.account) ||
|
|
83
|
+
(a.costCentre ?? '').localeCompare(b.costCentre ?? '') ||
|
|
84
|
+
a.month.localeCompare(b.month))
|
|
85
|
+
.map((s) => ({
|
|
86
|
+
account: s.account,
|
|
87
|
+
costCentre: s.costCentre,
|
|
88
|
+
month: s.month,
|
|
89
|
+
amount: moneyOf(s.amount, ledger.currency),
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
currency: ledger.currency,
|
|
93
|
+
balances,
|
|
94
|
+
accounts: [...seenAccounts]
|
|
95
|
+
.sort()
|
|
96
|
+
.map((number) => ({ number, name: accountNames.get(number) ?? '' })),
|
|
97
|
+
costCentres: [...seenCentres].sort().map((code) => ({ code, name: centreNames.get(code) ?? '' })),
|
|
98
|
+
outOfRange,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=aggregate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aggregate.js","sourceRoot":"","sources":["../src/aggregate.ts"],"names":[],"mappings":"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"}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ConnectorConnection } from '@substrat-run/kernel';
|
|
3
|
+
/**
|
|
4
|
+
* A thin, typed client over the Fortnox REST API and its OAuth2 token endpoint.
|
|
5
|
+
*
|
|
6
|
+
* Every call goes through the connection's `fetch`, never a global one: that is what
|
|
7
|
+
* gets it a timeout, an egress policy, and health recorded against the right
|
|
8
|
+
* connection. Module code cannot reach any of this — boundary-lint bans `fetch`
|
|
9
|
+
* outright — and a connector is host code.
|
|
10
|
+
*
|
|
11
|
+
* ## The two hosts are different, and mixing them is the first mistake
|
|
12
|
+
*
|
|
13
|
+
* OAuth lives at `apps.fortnox.se/oauth-v1`; the API lives at `api.fortnox.se/3`.
|
|
14
|
+
* They are separate origins with separate paths, so both are named here and neither
|
|
15
|
+
* is derived from the other.
|
|
16
|
+
*/
|
|
17
|
+
/** The OAuth2 host — `/auth` (consent) and `/token` (minting) hang off this. */
|
|
18
|
+
export declare const FORTNOX_OAUTH_BASE = "https://apps.fortnox.se/oauth-v1";
|
|
19
|
+
/** The REST host — every data read hangs off this. */
|
|
20
|
+
export declare const FORTNOX_API_BASE = "https://api.fortnox.se/3";
|
|
21
|
+
/**
|
|
22
|
+
* A Fortnox connection's credential — the CLIENT-CREDENTIALS triple.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT the authorization-code flow's tokens. Fortnox supports
|
|
25
|
+
* `grant_type=client_credentials` for a *service* consent, and that changes what a
|
|
26
|
+
* connection has to store: an access token is minted on demand from these three
|
|
27
|
+
* static values and lives one hour, so there is no refresh token — and therefore
|
|
28
|
+
* none of the rotation hazard the authorization-code flow carries. Fortnox's refresh
|
|
29
|
+
* tokens are single-use and rotating: two concurrent refreshes kill the connection,
|
|
30
|
+
* one winning and the other saving an already-dead token. A connector that mints
|
|
31
|
+
* from static credentials cannot reach that state at all.
|
|
32
|
+
*
|
|
33
|
+
* The price is a one-time consent round per company, in a browser, with
|
|
34
|
+
* `account_type=service` — see {@link fortnoxConsentUrl}. What that round yields is
|
|
35
|
+
* the `tenantId` below; the client pair comes from the Developer Portal and is the
|
|
36
|
+
* same for every company this integration serves.
|
|
37
|
+
*/
|
|
38
|
+
export declare const fortnoxSecret: z.ZodObject<{
|
|
39
|
+
clientId: z.ZodString;
|
|
40
|
+
clientSecret: z.ZodString;
|
|
41
|
+
tenantId: z.ZodString;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export type FortnoxSecret = z.infer<typeof fortnoxSecret>;
|
|
44
|
+
/**
|
|
45
|
+
* The consent URL a company's sysadmin visits ONCE (the PDF's step 1, with the one
|
|
46
|
+
* parameter that changes everything).
|
|
47
|
+
*
|
|
48
|
+
* `account_type=service` is what makes the resulting consent mintable by
|
|
49
|
+
* `client_credentials` afterwards. Without it the consent is bound to the person who
|
|
50
|
+
* granted it, and this connector's whole credential model does not apply — you are
|
|
51
|
+
* back to storing and rotating refresh tokens per company.
|
|
52
|
+
*
|
|
53
|
+
* `access_type=offline` is kept because the callback still exchanges a code once, to
|
|
54
|
+
* discover the `DatabaseNumber` this connection will be keyed by
|
|
55
|
+
* ({@link FortnoxApi.companyInformation}). After that the code path is never used
|
|
56
|
+
* again.
|
|
57
|
+
*
|
|
58
|
+
* **Scopes cannot be widened later without a new consent round.** Ask for what the
|
|
59
|
+
* integration will need, not what it needs today — this is the single most expensive
|
|
60
|
+
* thing to get wrong here, because fixing it means going back to every customer.
|
|
61
|
+
*/
|
|
62
|
+
export declare function fortnoxConsentUrl(input: {
|
|
63
|
+
clientId: string;
|
|
64
|
+
redirectUri: string;
|
|
65
|
+
/** e.g. `['bookkeeping', 'companyinformation']` — `bookkeeping` is what SIE export needs. */
|
|
66
|
+
scopes: readonly string[];
|
|
67
|
+
/** Opaque round-trip value; the caller correlates its own record by it. */
|
|
68
|
+
state: string;
|
|
69
|
+
oauthBase?: string;
|
|
70
|
+
}): string;
|
|
71
|
+
/** One financial year, as `GET /3/financialyears` returns it. Extra fields ignored. */
|
|
72
|
+
export declare const fortnoxFinancialYear: z.ZodObject<{
|
|
73
|
+
Id: z.ZodNumber;
|
|
74
|
+
FromDate: z.ZodString;
|
|
75
|
+
ToDate: z.ZodString;
|
|
76
|
+
AccountingMethod: z.ZodOptional<z.ZodString>;
|
|
77
|
+
}, z.core.$strip>;
|
|
78
|
+
export type FortnoxFinancialYear = z.infer<typeof fortnoxFinancialYear>;
|
|
79
|
+
/**
|
|
80
|
+
* The company, as `GET /3/companyinformation` returns it.
|
|
81
|
+
*
|
|
82
|
+
* `DatabaseNumber` is the whole reason this endpoint is called: it IS the `TenantId`
|
|
83
|
+
* header value, and it is how a connect flow learns which company the consent it just
|
|
84
|
+
* received belongs to. It is also the cheapest authenticated read Fortnox offers, so
|
|
85
|
+
* it doubles as the credential probe.
|
|
86
|
+
*/
|
|
87
|
+
export declare const fortnoxCompany: z.ZodObject<{
|
|
88
|
+
CompanyName: z.ZodDefault<z.ZodString>;
|
|
89
|
+
OrganizationNumber: z.ZodDefault<z.ZodString>;
|
|
90
|
+
DatabaseNumber: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
|
|
91
|
+
CountryCode: z.ZodOptional<z.ZodString>;
|
|
92
|
+
}, z.core.$strip>;
|
|
93
|
+
export type FortnoxCompany = z.infer<typeof fortnoxCompany>;
|
|
94
|
+
/**
|
|
95
|
+
* A Fortnox API failure, with the one bit a caller actually branches on.
|
|
96
|
+
*
|
|
97
|
+
* `refused` means the provider said "not with these credentials" — a 401 or 403.
|
|
98
|
+
* Everything else (a timeout, a 5xx, a parse failure) says nothing about the
|
|
99
|
+
* credential, and treating it as a refusal would make a Fortnox outage look like
|
|
100
|
+
* every tenant's keys going bad at once.
|
|
101
|
+
*/
|
|
102
|
+
export declare class FortnoxApiError extends Error {
|
|
103
|
+
readonly status: number;
|
|
104
|
+
readonly refused: boolean;
|
|
105
|
+
readonly body: string;
|
|
106
|
+
constructor(message: string, status: number, body: string);
|
|
107
|
+
}
|
|
108
|
+
export declare class FortnoxApi {
|
|
109
|
+
private readonly conn;
|
|
110
|
+
private readonly apiBase;
|
|
111
|
+
private readonly oauthBase;
|
|
112
|
+
/**
|
|
113
|
+
* The minted token and when it dies, cached for the life of THIS instance.
|
|
114
|
+
*
|
|
115
|
+
* Per-instance rather than per-connection-global on purpose: an instance is built
|
|
116
|
+
* for one sweep pass, so the cache lives exactly as long as the work that uses it
|
|
117
|
+
* and no cross-request state accumulates in a Worker's isolate. A token is an hour
|
|
118
|
+
* long and a pass is seconds, so one mint serves a whole pass.
|
|
119
|
+
*/
|
|
120
|
+
private token;
|
|
121
|
+
constructor(conn: ConnectorConnection, options?: {
|
|
122
|
+
apiBase?: string;
|
|
123
|
+
oauthBase?: string;
|
|
124
|
+
now?: () => number;
|
|
125
|
+
});
|
|
126
|
+
private readonly now;
|
|
127
|
+
private secret;
|
|
128
|
+
/**
|
|
129
|
+
* Mint (or reuse) an access token via `grant_type=client_credentials`.
|
|
130
|
+
*
|
|
131
|
+
* The 60-second skew is the PDF's, and it is right for a different reason here:
|
|
132
|
+
* there is no refresh token to lose, so an early re-mint costs one extra round trip
|
|
133
|
+
* rather than risking a rotation race. Cheap insurance against a token expiring
|
|
134
|
+
* mid-pass.
|
|
135
|
+
*/
|
|
136
|
+
accessToken(): Promise<string>;
|
|
137
|
+
/** A JSON GET against the REST host, authenticated with a freshly-ensured token. */
|
|
138
|
+
private getJson;
|
|
139
|
+
/** The company — the probe read, and where a connect flow learns `DatabaseNumber`. */
|
|
140
|
+
companyInformation(): Promise<FortnoxCompany>;
|
|
141
|
+
/** Every financial year the company has, newest first is NOT guaranteed — sort yourself. */
|
|
142
|
+
financialYears(): Promise<FortnoxFinancialYear[]>;
|
|
143
|
+
/**
|
|
144
|
+
* The whole year's bookkeeping as one SIE4 file — the read this connector exists for.
|
|
145
|
+
*
|
|
146
|
+
* **The response is ISO-8859-1, not UTF-8**, and nothing in the response says so.
|
|
147
|
+
* Decoding it as UTF-8 does not throw; it silently mangles every å/ä/ö in every
|
|
148
|
+
* account name, cost-centre label and verification text — which is a corrupted
|
|
149
|
+
* ledger that looks like a working one. So the bytes are taken as an ArrayBuffer and
|
|
150
|
+
* decoded explicitly, and this is the only place in the connector that knows.
|
|
151
|
+
*/
|
|
152
|
+
sieFile(financialYearId: number): Promise<string>;
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAQhE;;;;;;;;;;;;;GAaG;AAEH,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,qCAAqC,CAAC;AACrE,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;AAE3D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa;;;;iBAcxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,6FAA6F;IAC7F,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,MAAM,CAUT;AAED,uFAAuF;AACvF,eAAO,MAAM,oBAAoB;;;;;iBAK/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;;;iBAKzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAS5D;;;;;;;GAOG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,YAAY,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAMxD;CACF;AA+CD,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAsB;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,KAAK,CAAuD;IAEpE,YACE,IAAI,EAAE,mBAAmB,EACzB,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;KAAE,EAMvE;IAED,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC,OAAO,CAAC,MAAM;IAYd;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC,CA+BnC;IAED,oFAAoF;YACtE,OAAO;IAgBrB,sFAAsF;IAChF,kBAAkB,IAAI,OAAO,CAAC,cAAc,CAAC,CAKlD;IAED,4FAA4F;IACtF,cAAc,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC,CAGtD;IAED;;;;;;;;OAQG;IACG,OAAO,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAetD;CACF"}
|