@sha3/code-standards 0.1.0 → 0.1.2
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 +47 -9
- package/bin/code-standards.mjs +30 -36
- package/package.json +4 -2
- package/resources/ai/templates/examples/demo/src/billing/billing-service.ts +102 -0
- package/resources/ai/templates/examples/demo/src/invoices/invoice-errors.ts +89 -0
- package/resources/ai/templates/examples/demo/src/invoices/invoice-service.ts +123 -0
- package/resources/ai/templates/examples/demo/src/invoices/invoice-types.ts +20 -0
- package/resources/ai/templates/examples/rules/async-bad.ts +94 -0
- package/resources/ai/templates/examples/rules/async-good.ts +94 -0
- package/resources/ai/templates/examples/rules/class-first-bad.ts +90 -0
- package/resources/ai/templates/examples/rules/class-first-good.ts +99 -0
- package/resources/ai/templates/examples/rules/constructor-bad.ts +98 -0
- package/resources/ai/templates/examples/rules/constructor-good.ts +97 -0
- package/resources/ai/templates/examples/rules/control-flow-bad.ts +85 -0
- package/resources/ai/templates/examples/rules/control-flow-good.ts +92 -0
- package/resources/ai/templates/examples/rules/errors-bad.ts +86 -0
- package/resources/ai/templates/examples/rules/errors-good.ts +89 -0
- package/resources/ai/templates/examples/rules/functions-bad.ts +106 -0
- package/resources/ai/templates/examples/rules/functions-good.ts +102 -0
- package/resources/ai/templates/examples/rules/returns-bad.ts +92 -0
- package/resources/ai/templates/examples/rules/returns-good.ts +94 -0
- package/resources/ai/templates/examples/rules/testing-bad.ts +88 -0
- package/resources/ai/templates/examples/rules/testing-good.ts +92 -0
- package/resources/ai/templates/rules/architecture.md +5 -15
- package/resources/ai/templates/rules/async.md +2 -12
- package/resources/ai/templates/rules/class-first.md +23 -82
- package/resources/ai/templates/rules/control-flow.md +2 -8
- package/resources/ai/templates/rules/errors.md +2 -11
- package/resources/ai/templates/rules/functions.md +2 -15
- package/resources/ai/templates/rules/returns.md +2 -22
- package/resources/ai/templates/rules/testing.md +2 -14
- package/standards/style.md +9 -1
- package/templates/node-lib/gitignore +5 -0
- package/templates/node-service/gitignore +5 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
If you just want to start now:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
npx @sha3/code-standards init
|
|
21
|
+
npx @sha3/code-standards init --template node-service --yes
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Then in your AI chat, paste this:
|
|
@@ -64,7 +64,9 @@ Default generated contract includes structural class/file blocks such as:
|
|
|
64
64
|
|
|
65
65
|
Section marker format is fixed to:
|
|
66
66
|
|
|
67
|
-
-
|
|
67
|
+
- `/**`
|
|
68
|
+
- ` * @section <block-name>`
|
|
69
|
+
- ` */`
|
|
68
70
|
|
|
69
71
|
All blocks MUST exist even when empty (`// empty`).
|
|
70
72
|
|
|
@@ -85,14 +87,14 @@ No.
|
|
|
85
87
|
Default flow (no profile setup):
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
|
-
npx @sha3/code-standards init
|
|
90
|
+
npx @sha3/code-standards init --template node-service --yes
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
Custom profile flow:
|
|
92
94
|
|
|
93
95
|
```bash
|
|
94
96
|
npx @sha3/code-standards profile --profile ./profiles/team.profile.json
|
|
95
|
-
npx @sha3/code-standards init
|
|
97
|
+
npx @sha3/code-standards init --template node-service --yes --profile ./profiles/team.profile.json
|
|
96
98
|
```
|
|
97
99
|
|
|
98
100
|
---
|
|
@@ -103,10 +105,43 @@ After `init`, your new repo contains:
|
|
|
103
105
|
|
|
104
106
|
- `AGENTS.md` (blocking rules for AI)
|
|
105
107
|
- `ai/codex.md`, `ai/cursor.md`, `ai/copilot.md`, `ai/windsurf.md`
|
|
108
|
+
- `ai/examples/rules/*.ts` (good/bad examples per rule)
|
|
109
|
+
- `ai/examples/demo/src/*` (feature-folder demo with classes and section blocks)
|
|
110
|
+
- `.gitignore` preconfigured for Node/TypeScript output
|
|
106
111
|
- lint/format/typecheck/test-ready project template
|
|
107
112
|
|
|
108
113
|
That means the next step is **not** configuring tools. The next step is telling your assistant to obey `AGENTS.md` before coding.
|
|
109
114
|
|
|
115
|
+
## TypeScript Example Files
|
|
116
|
+
|
|
117
|
+
`init` now stores code examples in `.ts` files instead of embedding them inside `AGENTS.md`.
|
|
118
|
+
|
|
119
|
+
Demo structure generated by default:
|
|
120
|
+
|
|
121
|
+
- `ai/examples/demo/src/invoices/invoice-service.ts`
|
|
122
|
+
- `ai/examples/demo/src/invoices/invoice-errors.ts`
|
|
123
|
+
- `ai/examples/demo/src/invoices/invoice-types.ts`
|
|
124
|
+
- `ai/examples/demo/src/billing/billing-service.ts`
|
|
125
|
+
|
|
126
|
+
Rule-specific examples:
|
|
127
|
+
|
|
128
|
+
- `ai/examples/rules/class-first-good.ts`
|
|
129
|
+
- `ai/examples/rules/class-first-bad.ts`
|
|
130
|
+
- `ai/examples/rules/constructor-good.ts`
|
|
131
|
+
- `ai/examples/rules/constructor-bad.ts`
|
|
132
|
+
- `ai/examples/rules/functions-good.ts`
|
|
133
|
+
- `ai/examples/rules/functions-bad.ts`
|
|
134
|
+
- `ai/examples/rules/returns-good.ts`
|
|
135
|
+
- `ai/examples/rules/returns-bad.ts`
|
|
136
|
+
- `ai/examples/rules/async-good.ts`
|
|
137
|
+
- `ai/examples/rules/async-bad.ts`
|
|
138
|
+
- `ai/examples/rules/control-flow-good.ts`
|
|
139
|
+
- `ai/examples/rules/control-flow-bad.ts`
|
|
140
|
+
- `ai/examples/rules/errors-good.ts`
|
|
141
|
+
- `ai/examples/rules/errors-bad.ts`
|
|
142
|
+
- `ai/examples/rules/testing-good.ts`
|
|
143
|
+
- `ai/examples/rules/testing-bad.ts`
|
|
144
|
+
|
|
110
145
|
---
|
|
111
146
|
|
|
112
147
|
## How To Use With AI (Copy/Paste)
|
|
@@ -197,14 +232,16 @@ npx @sha3/code-standards profile \
|
|
|
197
232
|
|
|
198
233
|
### 2) Scaffold project
|
|
199
234
|
|
|
235
|
+
Run this inside the directory you want to initialize.
|
|
236
|
+
|
|
200
237
|
```bash
|
|
201
|
-
npx @sha3/code-standards init
|
|
238
|
+
npx @sha3/code-standards init --template node-service --yes
|
|
202
239
|
```
|
|
203
240
|
|
|
204
241
|
With explicit profile:
|
|
205
242
|
|
|
206
243
|
```bash
|
|
207
|
-
npx @sha3/code-standards init
|
|
244
|
+
npx @sha3/code-standards init \
|
|
208
245
|
--template node-lib \
|
|
209
246
|
--yes \
|
|
210
247
|
--no-install \
|
|
@@ -214,7 +251,7 @@ npx @sha3/code-standards init my-lib \
|
|
|
214
251
|
Skip AI files when needed:
|
|
215
252
|
|
|
216
253
|
```bash
|
|
217
|
-
npx @sha3/code-standards init
|
|
254
|
+
npx @sha3/code-standards init --template node-lib --yes --no-ai-adapters
|
|
218
255
|
```
|
|
219
256
|
|
|
220
257
|
### 3) Work loop inside generated project
|
|
@@ -234,15 +271,16 @@ Then use the prompts above in your AI tool.
|
|
|
234
271
|
code-standards <command> [options]
|
|
235
272
|
|
|
236
273
|
Commands:
|
|
237
|
-
init
|
|
274
|
+
init Initialize a project in the current directory
|
|
238
275
|
profile Create or update the AI style profile
|
|
239
276
|
```
|
|
240
277
|
|
|
241
278
|
### `init` options
|
|
242
279
|
|
|
280
|
+
`init` always uses the current working directory as target.
|
|
281
|
+
|
|
243
282
|
- `--template <node-lib|node-service>`
|
|
244
283
|
- `--yes`
|
|
245
|
-
- `--target <dir>`
|
|
246
284
|
- `--no-install`
|
|
247
285
|
- `--force`
|
|
248
286
|
- `--with-ai-adapters`
|
package/bin/code-standards.mjs
CHANGED
|
@@ -166,13 +166,12 @@ function printUsage() {
|
|
|
166
166
|
code-standards <command> [options]
|
|
167
167
|
|
|
168
168
|
Commands:
|
|
169
|
-
init
|
|
169
|
+
init Initialize a project in the current directory
|
|
170
170
|
profile Create or update the AI style profile
|
|
171
171
|
|
|
172
172
|
Init options:
|
|
173
173
|
--template <node-lib|node-service>
|
|
174
174
|
--yes
|
|
175
|
-
--target <dir>
|
|
176
175
|
--no-install
|
|
177
176
|
--force
|
|
178
177
|
--with-ai-adapters
|
|
@@ -192,11 +191,9 @@ function parseInitArgs(argv) {
|
|
|
192
191
|
const options = {
|
|
193
192
|
template: undefined,
|
|
194
193
|
yes: false,
|
|
195
|
-
target: undefined,
|
|
196
194
|
install: true,
|
|
197
195
|
force: false,
|
|
198
196
|
withAiAdapters: true,
|
|
199
|
-
projectName: undefined,
|
|
200
197
|
profilePath: undefined,
|
|
201
198
|
help: false
|
|
202
199
|
};
|
|
@@ -205,12 +202,9 @@ function parseInitArgs(argv) {
|
|
|
205
202
|
const token = argv[i];
|
|
206
203
|
|
|
207
204
|
if (!token.startsWith("-")) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
throw new Error(`Unexpected positional argument: ${token}`);
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Positional project names are not supported: ${token}. Run init from your target directory.`
|
|
207
|
+
);
|
|
214
208
|
}
|
|
215
209
|
|
|
216
210
|
if (token === "--template") {
|
|
@@ -230,15 +224,7 @@ function parseInitArgs(argv) {
|
|
|
230
224
|
}
|
|
231
225
|
|
|
232
226
|
if (token === "--target") {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!value || value.startsWith("-")) {
|
|
236
|
-
throw new Error("Missing value for --target");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
options.target = value;
|
|
240
|
-
i += 1;
|
|
241
|
-
continue;
|
|
227
|
+
throw new Error("--target is not supported. Run init from your target directory.");
|
|
242
228
|
}
|
|
243
229
|
|
|
244
230
|
if (token === "--profile") {
|
|
@@ -354,6 +340,15 @@ function replaceTokens(content, tokens) {
|
|
|
354
340
|
return output;
|
|
355
341
|
}
|
|
356
342
|
|
|
343
|
+
function mapTemplateFileName(fileName) {
|
|
344
|
+
// npm may rewrite .gitignore to .npmignore in published tarballs.
|
|
345
|
+
if (fileName === "gitignore" || fileName === ".npmignore") {
|
|
346
|
+
return ".gitignore";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return fileName;
|
|
350
|
+
}
|
|
351
|
+
|
|
357
352
|
function normalizeProfile(rawProfile) {
|
|
358
353
|
const normalized = {};
|
|
359
354
|
|
|
@@ -413,9 +408,9 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
|
|
|
413
408
|
|
|
414
409
|
for (const entry of entries) {
|
|
415
410
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
416
|
-
const targetPath = path.join(targetDir, entry.name);
|
|
417
411
|
|
|
418
412
|
if (entry.isDirectory()) {
|
|
413
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
419
414
|
await copyTemplateDirectory(sourcePath, targetPath, tokens);
|
|
420
415
|
continue;
|
|
421
416
|
}
|
|
@@ -426,6 +421,8 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
|
|
|
426
421
|
|
|
427
422
|
const raw = await readFile(sourcePath, "utf8");
|
|
428
423
|
const rendered = replaceTokens(raw, tokens);
|
|
424
|
+
const targetName = mapTemplateFileName(entry.name);
|
|
425
|
+
const targetPath = path.join(targetDir, targetName);
|
|
429
426
|
await writeFile(targetPath, rendered, "utf8");
|
|
430
427
|
}
|
|
431
428
|
}
|
|
@@ -790,9 +787,16 @@ async function renderAdapterFiles(packageRoot, targetDir, tokens) {
|
|
|
790
787
|
}
|
|
791
788
|
}
|
|
792
789
|
|
|
790
|
+
async function renderExampleFiles(packageRoot, targetDir, tokens) {
|
|
791
|
+
const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
|
|
792
|
+
const examplesTarget = path.join(targetDir, "ai", "examples");
|
|
793
|
+
await copyTemplateDirectory(examplesTemplateDir, examplesTarget, tokens);
|
|
794
|
+
}
|
|
795
|
+
|
|
793
796
|
async function generateAiInstructions(packageRoot, targetDir, tokens, profile) {
|
|
794
797
|
await renderProjectAgents(packageRoot, targetDir, tokens.projectName, profile);
|
|
795
798
|
await renderAdapterFiles(packageRoot, targetDir, tokens);
|
|
799
|
+
await renderExampleFiles(packageRoot, targetDir, tokens);
|
|
796
800
|
}
|
|
797
801
|
|
|
798
802
|
async function maybeInitializeProfileInteractively(packageRoot, profilePath) {
|
|
@@ -881,17 +885,6 @@ async function promptForMissing(options) {
|
|
|
881
885
|
resolved.template = normalized;
|
|
882
886
|
}
|
|
883
887
|
|
|
884
|
-
if (!resolved.projectName) {
|
|
885
|
-
const nameAnswer = await rl.question("Project name [my-project]: ");
|
|
886
|
-
resolved.projectName = nameAnswer.trim() || "my-project";
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
if (!resolved.target) {
|
|
890
|
-
const targetDefault = resolved.projectName;
|
|
891
|
-
const targetAnswer = await rl.question(`Target directory [${targetDefault}]: `);
|
|
892
|
-
resolved.target = targetAnswer.trim() || targetDefault;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
888
|
if (options.install) {
|
|
896
889
|
const installAnswer = await rl.question("Install dependencies now? (Y/n): ");
|
|
897
890
|
const normalized = installAnswer.trim().toLowerCase();
|
|
@@ -914,10 +907,12 @@ async function validateInitResources(packageRoot, templateName) {
|
|
|
914
907
|
"agents.project.template.md"
|
|
915
908
|
);
|
|
916
909
|
const adaptersTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "adapters");
|
|
910
|
+
const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
|
|
917
911
|
|
|
918
912
|
await access(templateDir, constants.R_OK);
|
|
919
913
|
await access(agentsTemplatePath, constants.R_OK);
|
|
920
914
|
await access(adaptersTemplateDir, constants.R_OK);
|
|
915
|
+
await access(examplesTemplateDir, constants.R_OK);
|
|
921
916
|
|
|
922
917
|
return { templateDir };
|
|
923
918
|
}
|
|
@@ -932,8 +927,6 @@ async function runInit(rawOptions) {
|
|
|
932
927
|
|
|
933
928
|
if (options.yes) {
|
|
934
929
|
options.template ??= "node-lib";
|
|
935
|
-
options.projectName ??= "my-project";
|
|
936
|
-
options.target ??= options.projectName;
|
|
937
930
|
} else {
|
|
938
931
|
options = await promptForMissing(options);
|
|
939
932
|
}
|
|
@@ -948,9 +941,11 @@ async function runInit(rawOptions) {
|
|
|
948
941
|
const schema = await loadProfileSchema(packageRoot);
|
|
949
942
|
const profile = await resolveProfileForInit(packageRoot, options, schema);
|
|
950
943
|
|
|
951
|
-
const
|
|
944
|
+
const targetPath = path.resolve(process.cwd());
|
|
945
|
+
const inferredProjectName = path.basename(targetPath);
|
|
946
|
+
const projectName =
|
|
947
|
+
inferredProjectName && inferredProjectName !== path.sep ? inferredProjectName : "my-project";
|
|
952
948
|
const packageName = sanitizePackageName(projectName);
|
|
953
|
-
const targetPath = path.resolve(process.cwd(), options.target ?? projectName);
|
|
954
949
|
|
|
955
950
|
await ensureTargetReady(targetPath, options.force);
|
|
956
951
|
|
|
@@ -975,7 +970,6 @@ async function runInit(rawOptions) {
|
|
|
975
970
|
|
|
976
971
|
console.log(`Project created at ${targetPath}`);
|
|
977
972
|
console.log("Next steps:");
|
|
978
|
-
console.log(` cd ${targetPath}`);
|
|
979
973
|
console.log(" npm run check");
|
|
980
974
|
}
|
|
981
975
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sha3/code-standards",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "AI-first code standards, tooling exports, and project initializer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -51,10 +51,12 @@
|
|
|
51
51
|
"hooks:install": "git config core.hooksPath .githooks",
|
|
52
52
|
"profile:validate": "node scripts/validate-profile.mjs"
|
|
53
53
|
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"ajv": "^8.17.1"
|
|
56
|
+
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"@eslint/js": "^9.20.0",
|
|
56
59
|
"@types/node": "^22.13.10",
|
|
57
|
-
"ajv": "^8.17.1",
|
|
58
60
|
"eslint": "^9.20.1",
|
|
59
61
|
"globals": "^15.15.0",
|
|
60
62
|
"prettier": "^3.5.3",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @section imports:externals
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// empty
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @section imports:internals
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { InvoiceService } from "../invoices/invoice-service.js";
|
|
12
|
+
import type { InvoiceSummary } from "../invoices/invoice-types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @section consts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CURRENCY_SYMBOL = "$";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @section types
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type BillingSnapshot = {
|
|
25
|
+
customerId: string;
|
|
26
|
+
invoiceCount: number;
|
|
27
|
+
totalAmount: number;
|
|
28
|
+
formattedTotal: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class BillingService {
|
|
32
|
+
/**
|
|
33
|
+
* @section private:attributes
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// empty
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @section private:properties
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
private readonly invoiceService: InvoiceService;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @section public:properties
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// empty
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @section constructor
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
public constructor(invoiceService: InvoiceService) {
|
|
55
|
+
this.invoiceService = invoiceService;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @section static:properties
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
// empty
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @section factory
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
public static create(invoiceService: InvoiceService): BillingService {
|
|
69
|
+
const service = new BillingService(invoiceService);
|
|
70
|
+
return service;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @section private:methods
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
private formatCurrency(amount: number): string {
|
|
78
|
+
const formattedAmount = `${CURRENCY_SYMBOL}${amount.toFixed(2)}`;
|
|
79
|
+
return formattedAmount;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @section public:methods
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
public async snapshot(customerId: string): Promise<BillingSnapshot> {
|
|
87
|
+
const summary: InvoiceSummary = await this.invoiceService.summarizeForCustomer(customerId);
|
|
88
|
+
const snapshot: BillingSnapshot = {
|
|
89
|
+
customerId,
|
|
90
|
+
invoiceCount: summary.count,
|
|
91
|
+
totalAmount: summary.totalAmount,
|
|
92
|
+
formattedTotal: this.formatCurrency(summary.totalAmount)
|
|
93
|
+
};
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @section static:methods
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
// empty
|
|
102
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @section imports:externals
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// empty
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @section imports:internals
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// empty
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @section consts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// empty
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @section types
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// empty
|
|
24
|
+
|
|
25
|
+
export class InvalidInvoiceCommandError extends Error {
|
|
26
|
+
/**
|
|
27
|
+
* @section private:attributes
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// empty
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @section private:properties
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
private readonly reason: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @section public:properties
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// empty
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @section constructor
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
public constructor(reason: string) {
|
|
49
|
+
super(`Invalid invoice command: ${reason}`);
|
|
50
|
+
this.name = "InvalidInvoiceCommandError";
|
|
51
|
+
this.reason = reason;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @section static:properties
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
// empty
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @section factory
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
public static forReason(reason: string): InvalidInvoiceCommandError {
|
|
65
|
+
const error = new InvalidInvoiceCommandError(reason);
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @section private:methods
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
// empty
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @section public:methods
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
public getReason(): string {
|
|
80
|
+
const value = this.reason;
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @section static:methods
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
// empty
|
|
89
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @section imports:externals
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @section imports:internals
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { InvalidInvoiceCommandError } from "./invoice-errors.js";
|
|
12
|
+
import type { CreateInvoiceCommand, Invoice, InvoiceSummary } from "./invoice-types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @section consts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MINIMUM_INVOICE_AMOUNT = 0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @section types
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// empty
|
|
25
|
+
|
|
26
|
+
export class InvoiceService {
|
|
27
|
+
/**
|
|
28
|
+
* @section private:attributes
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// empty
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @section private:properties
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
private readonly invoicesById: Map<string, Invoice>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @section public:properties
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// empty
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @section constructor
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
public constructor() {
|
|
50
|
+
this.invoicesById = new Map<string, Invoice>();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @section static:properties
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// empty
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @section factory
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
public static create(): InvoiceService {
|
|
64
|
+
const service = new InvoiceService();
|
|
65
|
+
return service;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @section private:methods
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
private validate(command: CreateInvoiceCommand): void {
|
|
73
|
+
if (!command.customerId.trim()) {
|
|
74
|
+
throw InvalidInvoiceCommandError.forReason("customerId is required");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (command.amount <= MINIMUM_INVOICE_AMOUNT) {
|
|
78
|
+
throw InvalidInvoiceCommandError.forReason("amount must be greater than zero");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private toInvoice(command: CreateInvoiceCommand): Invoice {
|
|
83
|
+
const invoice: Invoice = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
customerId: command.customerId,
|
|
86
|
+
amount: command.amount,
|
|
87
|
+
issuedAt: new Date()
|
|
88
|
+
};
|
|
89
|
+
return invoice;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @section public:methods
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
public async create(command: CreateInvoiceCommand): Promise<Invoice> {
|
|
97
|
+
this.validate(command);
|
|
98
|
+
const createdInvoice: Invoice = this.toInvoice(command);
|
|
99
|
+
this.invoicesById.set(createdInvoice.id, createdInvoice);
|
|
100
|
+
return createdInvoice;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public async summarizeForCustomer(customerId: string): Promise<InvoiceSummary> {
|
|
104
|
+
const allInvoices: Invoice[] = Array.from(this.invoicesById.values());
|
|
105
|
+
const invoices: Invoice[] = allInvoices.filter((invoice) => {
|
|
106
|
+
return invoice.customerId === customerId;
|
|
107
|
+
});
|
|
108
|
+
const totalAmount = invoices.reduce((sum, invoice) => {
|
|
109
|
+
return sum + invoice.amount;
|
|
110
|
+
}, 0);
|
|
111
|
+
const summary: InvoiceSummary = {
|
|
112
|
+
count: invoices.length,
|
|
113
|
+
totalAmount
|
|
114
|
+
};
|
|
115
|
+
return summary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @section static:methods
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
// empty
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type InvoiceId = string;
|
|
2
|
+
|
|
3
|
+
export type CustomerId = string;
|
|
4
|
+
|
|
5
|
+
export type Invoice = {
|
|
6
|
+
id: InvoiceId;
|
|
7
|
+
customerId: CustomerId;
|
|
8
|
+
amount: number;
|
|
9
|
+
issuedAt: Date;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CreateInvoiceCommand = {
|
|
13
|
+
customerId: CustomerId;
|
|
14
|
+
amount: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type InvoiceSummary = {
|
|
18
|
+
count: number;
|
|
19
|
+
totalAmount: number;
|
|
20
|
+
};
|