@dooer/dooer-test-env 1.4.1 → 1.5.1
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/lib/account.js +87 -63
- package/lib/api.js +103 -0
- package/package.json +1 -1
package/lib/account.js
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
const crypto = require('crypto')
|
|
17
17
|
const { Client } = require('pg')
|
|
18
|
+
const { apiRequest, serviceToken } = require('./api')
|
|
18
19
|
|
|
19
|
-
// Ghost Inspector's active subscription set on staging (Jimmy 2026-09-01).
|
|
20
|
-
|
|
20
|
+
// Ghost Inspector's active subscription set on staging (Jimmy 2026-09-01). ORDER MATTERS: service-accounts
|
|
21
|
+
// rejects `billing` unless a `sales` subscription already exists (prerequisite-subscription-not-found), so
|
|
22
|
+
// sales is created first — a rule the old direct-INSERT path silently bypassed.
|
|
23
|
+
const SUBSCRIPTION_TYPES = ['accounting', 'sales', 'billing', 'salaries', 'prebook']
|
|
21
24
|
|
|
22
25
|
// The partner every new test account is placed under by default (Jimmy 2026-09-01).
|
|
23
26
|
const DEFAULT_PARTNER_DOMAIN = 'dooer'
|
|
@@ -85,7 +88,7 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
85
88
|
])
|
|
86
89
|
if (!partner.rowCount) throw new Error(`partner '${DEFAULT_PARTNER_DOMAIN}' not found in the local DB`)
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
let orgId = uuid()
|
|
89
92
|
const shortName = await uniqueSlug(client, name)
|
|
90
93
|
const plan = {
|
|
91
94
|
orgId,
|
|
@@ -104,51 +107,75 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
104
107
|
return plan
|
|
105
108
|
}
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
[
|
|
121
|
-
orgId,
|
|
110
|
+
// ── Provision through the product APIs, NOT direct SQL ──────────────────────────────────────────
|
|
111
|
+
// API writes publish domain events; service-closing generates accounting periods from
|
|
112
|
+
// `company-created` and `fiscal-year-created`, and VAT periods follow the same way. A direct INSERT
|
|
113
|
+
// yields rows with none of that ("no period exists"). (Jimmy 2026-09-03.)
|
|
114
|
+
const token = serviceToken()
|
|
115
|
+
const acct = (method, path, body, label) =>
|
|
116
|
+
apiRequest({ service: 'service-accounts', method, path, body, token, label })
|
|
117
|
+
|
|
118
|
+
// 1. the organization + owner (fires company-created → period generator)
|
|
119
|
+
const created = acct(
|
|
120
|
+
'POST',
|
|
121
|
+
'/v1/organizations',
|
|
122
|
+
{
|
|
122
123
|
name,
|
|
123
124
|
shortName,
|
|
124
|
-
orgNumber(),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
fiscalYear ?
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
125
|
+
organizationNumber: orgNumber(),
|
|
126
|
+
vatNumber: null,
|
|
127
|
+
countryCode: 'SE',
|
|
128
|
+
companyType: 'AB',
|
|
129
|
+
address: { addressLines: ['Testgatan 1'], postalCode: '11111', city: 'Stockholm', country: 'SE' },
|
|
130
|
+
bookkeepingStartDate: fiscalYear ? fy.start : null,
|
|
131
|
+
bookkeepingEndDate: null,
|
|
132
|
+
bookkeepingMethod: 'invoiceBased',
|
|
133
|
+
businessDescription: null,
|
|
134
|
+
isEmployer: !!fiscalYear,
|
|
135
|
+
isInactivated: false,
|
|
136
|
+
ownerUserId,
|
|
137
|
+
language: 'sv',
|
|
138
|
+
timeZone: 'Europe/Stockholm',
|
|
139
|
+
sniCodes: [],
|
|
140
|
+
tags: [],
|
|
141
|
+
},
|
|
142
|
+
'create organization'
|
|
140
143
|
)
|
|
144
|
+
orgId = (created.json && created.json.id) || orgId
|
|
145
|
+
plan.orgId = orgId
|
|
146
|
+
|
|
147
|
+
// 2. subscriptions
|
|
141
148
|
for (const type of SUBSCRIPTION_TYPES) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
acct(
|
|
150
|
+
'POST',
|
|
151
|
+
`/v1/organizations/${orgId}/subscriptions`,
|
|
152
|
+
{ subscriptionType: type, status: 'active', startDate: fy.start },
|
|
153
|
+
`subscription ${type}`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. registration fields that only PATCH accepts (VAT / F-skatt / SKV fiscal year)
|
|
158
|
+
if (fiscalYear) {
|
|
159
|
+
acct(
|
|
160
|
+
'PATCH',
|
|
161
|
+
`/v1/organizations/${orgId}`,
|
|
162
|
+
{
|
|
163
|
+
isVatRegistered: true,
|
|
164
|
+
vatRegistrationDate: fy.start,
|
|
165
|
+
isFSkattRegistered: true,
|
|
166
|
+
isEmployer: true,
|
|
167
|
+
bookkeepingStartDate: fy.start,
|
|
168
|
+
skvFiscalYear: { startDate: fy.start, endDate: fy.end },
|
|
169
|
+
},
|
|
170
|
+
'organization information'
|
|
147
171
|
)
|
|
148
172
|
}
|
|
173
|
+
|
|
174
|
+
// 4. the partner link — no dedicated API route, and nothing derives from it
|
|
149
175
|
await client.query(
|
|
150
176
|
`INSERT INTO service_accounts."partnerOrganization" (id, "partnerId", "organizationId", "createdAt", "updatedAt")
|
|
151
|
-
VALUES ($1,$2,$3,now(),now())
|
|
177
|
+
VALUES ($1,$2,$3,now(),now())
|
|
178
|
+
ON CONFLICT DO NOTHING`,
|
|
152
179
|
[uuid(), partner.rows[0].id, orgId]
|
|
153
180
|
)
|
|
154
181
|
// Pre-accept the current Terms of Service so the account is ready to use (sumify otherwise prompts and
|
|
@@ -166,41 +193,38 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
|
|
|
166
193
|
plan.tosAccepted = activeTos.rowCount
|
|
167
194
|
|
|
168
195
|
if (fiscalYear) {
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
// 6. the fiscal year, through service-ledger (fires fiscal-year-created → service-closing generates
|
|
197
|
+
// the accounting periods; a direct INSERT is what produced "no period exists").
|
|
198
|
+
apiRequest({
|
|
199
|
+
service: 'service-ledger',
|
|
200
|
+
method: 'POST',
|
|
201
|
+
path: `/v1/organizations/${orgId}/fiscal-years`,
|
|
202
|
+
body: { startDate: fy.start, endDate: fy.end, chartOfAccountsName: 'dooer-ab' },
|
|
203
|
+
token,
|
|
204
|
+
label: 'create fiscal year',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// 7. the temporal key/values the platform reads. `fiscalYear` carries the period in both the value
|
|
208
|
+
// and the row's own start/endDate (matching how ibSync writes it); the registration flags are
|
|
209
|
+
// open-ended from the start of the year.
|
|
178
210
|
const tkv = [
|
|
179
211
|
{ key: 'fiscalYear', value: { start: fy.start, end: fy.end }, startDate: fy.start, endDate: fy.end },
|
|
180
212
|
{ key: 'hasVatRegistration', value: true, startDate: fy.start, endDate: null },
|
|
181
213
|
{ key: 'hasCompanyTax', value: true, startDate: fy.start, endDate: null }, // F-skatt
|
|
182
214
|
{ key: 'hasEmployeeRegistration', value: true, startDate: fy.start, endDate: null },
|
|
215
|
+
// REQUIRED for booking: HQ's booking view reads the method from the TEMPORAL key, not the
|
|
216
|
+
// companies column (lib/use-bookkeeping-method: temporalKeyValueByDate(key:"bookkeepingMethod")),
|
|
217
|
+
// and disables the Spara button while it is null (new-document-details/wrapper.tsx). Ghost
|
|
218
|
+
// Inspector has the column NULL but this key set — the column alone is not enough.
|
|
219
|
+
{ key: 'bookkeepingMethod', value: 'invoiceBased', startDate: fy.start, endDate: null },
|
|
183
220
|
]
|
|
184
221
|
for (const t of tkv) {
|
|
185
|
-
|
|
186
|
-
`INSERT INTO service_accounts."temporalKeyValue"
|
|
187
|
-
(id, "organizationId", key, value, "startDate", "endDate", "setBySource", "createdAt", "updatedAt")
|
|
188
|
-
VALUES ($1,$2,$3,$4::jsonb,$5,$6,'user',now(),now())`,
|
|
189
|
-
[uuid(), orgId, t.key, JSON.stringify(t.value), t.startDate, t.endDate]
|
|
190
|
-
)
|
|
222
|
+
acct('POST', `/v1/organizations/${orgId}/temporal-key-values`, t, `temporal key ${t.key}`)
|
|
191
223
|
}
|
|
192
224
|
plan.temporalKeys = tkv.map((t) => t.key)
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
await client.query('COMMIT')
|
|
196
227
|
return plan
|
|
197
|
-
} catch (e) {
|
|
198
|
-
try {
|
|
199
|
-
await client.query('ROLLBACK')
|
|
200
|
-
} catch (_) {
|
|
201
|
-
/* ignore */
|
|
202
|
-
}
|
|
203
|
-
throw e
|
|
204
228
|
} finally {
|
|
205
229
|
await client.end()
|
|
206
230
|
}
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Call a running service's own HTTP API from the host.
|
|
2
|
+
//
|
|
3
|
+
// Backend services are NOT published to the host (only public-facade/service-graphql are), and the facade
|
|
4
|
+
// only proxies a whitelisted route set — so we talk to a service the same way its peers do: from INSIDE the
|
|
5
|
+
// compose network, by exec'ing into that service's container and calling its localhost:3000.
|
|
6
|
+
//
|
|
7
|
+
// Why the API and not direct SQL: writes through the API publish domain events. service-closing generates
|
|
8
|
+
// accounting periods from `company-created` and `fiscal-year-created`; VAT periods and other derived state
|
|
9
|
+
// come the same way. A direct INSERT produces a row with none of that, so the account looks provisioned but
|
|
10
|
+
// has "no period exists" everywhere. (Jimmy 2026-09-03.)
|
|
11
|
+
|
|
12
|
+
const { spawnSync } = require('child_process')
|
|
13
|
+
const { mint } = require('./service-token')
|
|
14
|
+
const { AUTH_PRIVATE_KEY_B64 } = require('./auth-dev-keys')
|
|
15
|
+
|
|
16
|
+
const containerFor = (service) => `dooer-test-env-${service}-1`
|
|
17
|
+
|
|
18
|
+
// A service token is accepted by every service and bypasses per-user authorization.
|
|
19
|
+
function serviceToken(serviceId = 'dooer-test-env') {
|
|
20
|
+
return mint({ serviceId, privateKeyPem: Buffer.from(AUTH_PRIVATE_KEY_B64, 'base64').toString('utf8') })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The request runs in the target container: payload/token/path go in as env vars (no quoting hazards).
|
|
24
|
+
const REQUEST_SCRIPT = `
|
|
25
|
+
const http = require('http')
|
|
26
|
+
const body = process.env.DTE_BODY || ''
|
|
27
|
+
const req = http.request(
|
|
28
|
+
{
|
|
29
|
+
host: 'localhost',
|
|
30
|
+
port: 3000,
|
|
31
|
+
method: process.env.DTE_METHOD,
|
|
32
|
+
path: process.env.DTE_PATH,
|
|
33
|
+
headers: Object.assign(
|
|
34
|
+
{ authorization: 'Bearer ' + process.env.DTE_TOKEN, 'x-dooer-client': 'dooer-test-env@0' },
|
|
35
|
+
body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {}
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
(res) => {
|
|
39
|
+
let d = ''
|
|
40
|
+
res.on('data', (c) => (d += c))
|
|
41
|
+
res.on('end', () => {
|
|
42
|
+
process.stdout.write(JSON.stringify({ status: res.statusCode, body: d }))
|
|
43
|
+
process.exit(0)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
req.on('error', (e) => {
|
|
48
|
+
process.stdout.write(JSON.stringify({ status: 0, body: String(e.message) }))
|
|
49
|
+
process.exit(0)
|
|
50
|
+
})
|
|
51
|
+
if (body) req.write(body)
|
|
52
|
+
req.end()
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
// Perform one request. Returns { status, json }. Throws on a non-2xx (with the service's own error body).
|
|
56
|
+
function apiRequest({ service, method, path, body, token, label }) {
|
|
57
|
+
const payload = body === undefined ? '' : JSON.stringify(body)
|
|
58
|
+
const r = spawnSync(
|
|
59
|
+
'docker',
|
|
60
|
+
[
|
|
61
|
+
'exec',
|
|
62
|
+
'-e',
|
|
63
|
+
`DTE_METHOD=${method}`,
|
|
64
|
+
'-e',
|
|
65
|
+
`DTE_PATH=${path}`,
|
|
66
|
+
'-e',
|
|
67
|
+
`DTE_TOKEN=${token}`,
|
|
68
|
+
'-e',
|
|
69
|
+
`DTE_BODY=${payload}`,
|
|
70
|
+
containerFor(service),
|
|
71
|
+
'node',
|
|
72
|
+
'-e',
|
|
73
|
+
REQUEST_SCRIPT,
|
|
74
|
+
],
|
|
75
|
+
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }
|
|
76
|
+
)
|
|
77
|
+
if (r.status !== 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`cannot reach ${service} (is the env up? \`dooer-test-env up\`): ${(r.stderr || '').trim().slice(0, 300)}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
// The container prints our JSON last; ignore any logging the app wrote to stdout first.
|
|
83
|
+
const out = (r.stdout || '').trim()
|
|
84
|
+
const start = out.lastIndexOf('{"status"')
|
|
85
|
+
let parsed
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(start >= 0 ? out.slice(start) : out)
|
|
88
|
+
} catch (_) {
|
|
89
|
+
throw new Error(`${label || path}: unreadable response from ${service}: ${out.slice(0, 300)}`)
|
|
90
|
+
}
|
|
91
|
+
if (parsed.status < 200 || parsed.status >= 300) {
|
|
92
|
+
throw new Error(`${label || `${method} ${path}`} failed (${parsed.status}): ${String(parsed.body).slice(0, 500)}`)
|
|
93
|
+
}
|
|
94
|
+
let json = null
|
|
95
|
+
try {
|
|
96
|
+
json = parsed.body ? JSON.parse(parsed.body) : null
|
|
97
|
+
} catch (_) {
|
|
98
|
+
/* empty/non-JSON body (e.g. 204) */
|
|
99
|
+
}
|
|
100
|
+
return { status: parsed.status, json }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { apiRequest, serviceToken, containerFor }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": "Dooer/cli-dooer-test-env",
|