@dooer/dooer-test-env 1.4.1 → 1.5.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.
Files changed (3) hide show
  1. package/lib/account.js +82 -63
  2. package/lib/api.js +103 -0
  3. 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
- const SUBSCRIPTION_TYPES = ['accounting', 'billing', 'salaries', 'sales', 'prebook']
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
- const orgId = uuid()
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
- await client.query('BEGIN')
108
- await client.query(
109
- // Populate a placeholder address: some org endpoints serialize organization.address as a required
110
- // OBJECT and return null (schema violation, seen in sumify) when all address columns are empty.
111
- // The registration/fiscal-year columns are NULL unless the defaults are enabled (--no-fiscal-year).
112
- `INSERT INTO service_accounts.companies
113
- (companies_pk, fk_countries_pk, fk_company_types_pk, company_name, short_name, organization_number,
114
- api_uuid, language, "timeZone", currency, address, postal_code, city,
115
- bookkeeping_start_date, skv_fiscal_year_start, skv_fiscal_year_end,
116
- is_registered_for_vat, vat_registration_date, registered_for_f_tax, is_employer,
117
- inserted_at, updated_at)
118
- VALUES ($1,'SE','AB',$2,$3,$4,$5,'sv','Europe/Stockholm','SEK','Testgatan 1','11111','Stockholm',
119
- $6,$7,$8,$9,$10,$11,$12,now(),now())`,
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
- uuid(),
126
- fiscalYear ? fy.start : null, // bookkeeping_start_date
127
- fiscalYear ? fy.start : null, // skv_fiscal_year_start
128
- fiscalYear ? fy.end : null, // skv_fiscal_year_end
129
- fiscalYear ? true : null, // is_registered_for_vat
130
- fiscalYear ? fy.start : null, // vat_registration_date
131
- fiscalYear ? true : null, // registered_for_f_tax
132
- fiscalYear ? true : null, // is_employer
133
- ]
134
- )
135
- await client.query(
136
- `INSERT INTO service_accounts.companies2users
137
- (fk_companies_pk, fk_users_pk, fk_user_roles_at_companies_pk, api_uuid)
138
- VALUES ($1,$2,'Owner',$3)`,
139
- [orgId, ownerUserId, uuid()]
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
- await client.query(
143
- `INSERT INTO service_accounts.subscriptions
144
- (subscriptions_pk, fk_companies_pk, subscription_type, status, start_date, api_uuid, inserted_at, updated_at)
145
- VALUES ($1,$2,$3,'active',now(),$4,now(),now())`,
146
- [uuid(), orgId, type, uuid()]
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,15 +193,20 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
166
193
  plan.tosAccepted = activeTos.rowCount
167
194
 
168
195
  if (fiscalYear) {
169
- // The fiscal year itself (service-ledger owns it; HQ's "Räkenskapsår" + all booking depends on it).
170
- await client.query(
171
- `INSERT INTO service_ledger.fiscal_year (id, company_id, start_date, end_date, created_at, closed)
172
- VALUES ($1,$2,$3,$4,now(),false)`,
173
- [uuid(), orgId, fy.start, fy.end]
174
- )
175
- // The temporal source of truth mirroring the columns set above. `fiscalYear` carries the period in
176
- // both the value and the row's own start/endDate (matching how ibSync writes it); the registration
177
- // flags are open-ended from the start of the year.
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 },
@@ -182,25 +214,12 @@ async function createAccount({ local, name, ownerUserId, execute = false, fiscal
182
214
  { key: 'hasEmployeeRegistration', value: true, startDate: fy.start, endDate: null },
183
215
  ]
184
216
  for (const t of tkv) {
185
- await client.query(
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
- )
217
+ acct('POST', `/v1/organizations/${orgId}/temporal-key-values`, t, `temporal key ${t.key}`)
191
218
  }
192
219
  plan.temporalKeys = tkv.map((t) => t.key)
193
220
  }
194
221
 
195
- await client.query('COMMIT')
196
222
  return plan
197
- } catch (e) {
198
- try {
199
- await client.query('ROLLBACK')
200
- } catch (_) {
201
- /* ignore */
202
- }
203
- throw e
204
223
  } finally {
205
224
  await client.end()
206
225
  }
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.4.1",
3
+ "version": "1.5.0",
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",