@sha3/code-standards 0.1.1 → 0.1.3
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 +50 -9
- package/bin/code-standards.mjs +32 -37
- package/package.json +1 -1
- 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 +4 -15
- package/resources/ai/templates/rules/returns.md +2 -22
- package/resources/ai/templates/rules/testing.md +3 -14
- package/standards/architecture.md +1 -1
- package/standards/style.md +11 -1
- package/standards/testing.md +1 -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,45 @@ 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
|
+
Generated project code is TypeScript-only: implementation and tests live in `.ts` files.
|
|
116
|
+
|
|
117
|
+
## TypeScript Example Files
|
|
118
|
+
|
|
119
|
+
`init` now stores code examples in `.ts` files instead of embedding them inside `AGENTS.md`.
|
|
120
|
+
|
|
121
|
+
Demo structure generated by default:
|
|
122
|
+
|
|
123
|
+
- `ai/examples/demo/src/invoices/invoice-service.ts`
|
|
124
|
+
- `ai/examples/demo/src/invoices/invoice-errors.ts`
|
|
125
|
+
- `ai/examples/demo/src/invoices/invoice-types.ts`
|
|
126
|
+
- `ai/examples/demo/src/billing/billing-service.ts`
|
|
127
|
+
|
|
128
|
+
Rule-specific examples:
|
|
129
|
+
|
|
130
|
+
- `ai/examples/rules/class-first-good.ts`
|
|
131
|
+
- `ai/examples/rules/class-first-bad.ts`
|
|
132
|
+
- `ai/examples/rules/constructor-good.ts`
|
|
133
|
+
- `ai/examples/rules/constructor-bad.ts`
|
|
134
|
+
- `ai/examples/rules/functions-good.ts`
|
|
135
|
+
- `ai/examples/rules/functions-bad.ts`
|
|
136
|
+
- `ai/examples/rules/returns-good.ts`
|
|
137
|
+
- `ai/examples/rules/returns-bad.ts`
|
|
138
|
+
- `ai/examples/rules/async-good.ts`
|
|
139
|
+
- `ai/examples/rules/async-bad.ts`
|
|
140
|
+
- `ai/examples/rules/control-flow-good.ts`
|
|
141
|
+
- `ai/examples/rules/control-flow-bad.ts`
|
|
142
|
+
- `ai/examples/rules/errors-good.ts`
|
|
143
|
+
- `ai/examples/rules/errors-bad.ts`
|
|
144
|
+
- `ai/examples/rules/testing-good.ts`
|
|
145
|
+
- `ai/examples/rules/testing-bad.ts`
|
|
146
|
+
|
|
110
147
|
---
|
|
111
148
|
|
|
112
149
|
## How To Use With AI (Copy/Paste)
|
|
@@ -197,14 +234,16 @@ npx @sha3/code-standards profile \
|
|
|
197
234
|
|
|
198
235
|
### 2) Scaffold project
|
|
199
236
|
|
|
237
|
+
Run this inside the directory you want to initialize.
|
|
238
|
+
|
|
200
239
|
```bash
|
|
201
|
-
npx @sha3/code-standards init
|
|
240
|
+
npx @sha3/code-standards init --template node-service --yes
|
|
202
241
|
```
|
|
203
242
|
|
|
204
243
|
With explicit profile:
|
|
205
244
|
|
|
206
245
|
```bash
|
|
207
|
-
npx @sha3/code-standards init
|
|
246
|
+
npx @sha3/code-standards init \
|
|
208
247
|
--template node-lib \
|
|
209
248
|
--yes \
|
|
210
249
|
--no-install \
|
|
@@ -214,7 +253,7 @@ npx @sha3/code-standards init my-lib \
|
|
|
214
253
|
Skip AI files when needed:
|
|
215
254
|
|
|
216
255
|
```bash
|
|
217
|
-
npx @sha3/code-standards init
|
|
256
|
+
npx @sha3/code-standards init --template node-lib --yes --no-ai-adapters
|
|
218
257
|
```
|
|
219
258
|
|
|
220
259
|
### 3) Work loop inside generated project
|
|
@@ -234,15 +273,17 @@ Then use the prompts above in your AI tool.
|
|
|
234
273
|
code-standards <command> [options]
|
|
235
274
|
|
|
236
275
|
Commands:
|
|
237
|
-
init
|
|
276
|
+
init Initialize a project in the current directory
|
|
238
277
|
profile Create or update the AI style profile
|
|
239
278
|
```
|
|
240
279
|
|
|
241
280
|
### `init` options
|
|
242
281
|
|
|
282
|
+
`init` always uses the current working directory as target.
|
|
283
|
+
An existing `.git/` directory is allowed without `--force`.
|
|
284
|
+
|
|
243
285
|
- `--template <node-lib|node-service>`
|
|
244
286
|
- `--yes`
|
|
245
|
-
- `--target <dir>`
|
|
246
287
|
- `--no-install`
|
|
247
288
|
- `--force`
|
|
248
289
|
- `--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
|
|
|
@@ -399,8 +394,9 @@ async function ensureTargetReady(targetPath, force) {
|
|
|
399
394
|
}
|
|
400
395
|
|
|
401
396
|
const entries = await readdir(targetPath);
|
|
397
|
+
const nonGitEntries = entries.filter((entry) => entry !== ".git");
|
|
402
398
|
|
|
403
|
-
if (
|
|
399
|
+
if (nonGitEntries.length > 0 && !force) {
|
|
404
400
|
throw new Error(
|
|
405
401
|
`Target directory is not empty: ${targetPath}. Use --force to continue and overwrite files.`
|
|
406
402
|
);
|
|
@@ -413,9 +409,9 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
|
|
|
413
409
|
|
|
414
410
|
for (const entry of entries) {
|
|
415
411
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
416
|
-
const targetPath = path.join(targetDir, entry.name);
|
|
417
412
|
|
|
418
413
|
if (entry.isDirectory()) {
|
|
414
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
419
415
|
await copyTemplateDirectory(sourcePath, targetPath, tokens);
|
|
420
416
|
continue;
|
|
421
417
|
}
|
|
@@ -426,6 +422,8 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
|
|
|
426
422
|
|
|
427
423
|
const raw = await readFile(sourcePath, "utf8");
|
|
428
424
|
const rendered = replaceTokens(raw, tokens);
|
|
425
|
+
const targetName = mapTemplateFileName(entry.name);
|
|
426
|
+
const targetPath = path.join(targetDir, targetName);
|
|
429
427
|
await writeFile(targetPath, rendered, "utf8");
|
|
430
428
|
}
|
|
431
429
|
}
|
|
@@ -790,9 +788,16 @@ async function renderAdapterFiles(packageRoot, targetDir, tokens) {
|
|
|
790
788
|
}
|
|
791
789
|
}
|
|
792
790
|
|
|
791
|
+
async function renderExampleFiles(packageRoot, targetDir, tokens) {
|
|
792
|
+
const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
|
|
793
|
+
const examplesTarget = path.join(targetDir, "ai", "examples");
|
|
794
|
+
await copyTemplateDirectory(examplesTemplateDir, examplesTarget, tokens);
|
|
795
|
+
}
|
|
796
|
+
|
|
793
797
|
async function generateAiInstructions(packageRoot, targetDir, tokens, profile) {
|
|
794
798
|
await renderProjectAgents(packageRoot, targetDir, tokens.projectName, profile);
|
|
795
799
|
await renderAdapterFiles(packageRoot, targetDir, tokens);
|
|
800
|
+
await renderExampleFiles(packageRoot, targetDir, tokens);
|
|
796
801
|
}
|
|
797
802
|
|
|
798
803
|
async function maybeInitializeProfileInteractively(packageRoot, profilePath) {
|
|
@@ -881,17 +886,6 @@ async function promptForMissing(options) {
|
|
|
881
886
|
resolved.template = normalized;
|
|
882
887
|
}
|
|
883
888
|
|
|
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
889
|
if (options.install) {
|
|
896
890
|
const installAnswer = await rl.question("Install dependencies now? (Y/n): ");
|
|
897
891
|
const normalized = installAnswer.trim().toLowerCase();
|
|
@@ -914,10 +908,12 @@ async function validateInitResources(packageRoot, templateName) {
|
|
|
914
908
|
"agents.project.template.md"
|
|
915
909
|
);
|
|
916
910
|
const adaptersTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "adapters");
|
|
911
|
+
const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
|
|
917
912
|
|
|
918
913
|
await access(templateDir, constants.R_OK);
|
|
919
914
|
await access(agentsTemplatePath, constants.R_OK);
|
|
920
915
|
await access(adaptersTemplateDir, constants.R_OK);
|
|
916
|
+
await access(examplesTemplateDir, constants.R_OK);
|
|
921
917
|
|
|
922
918
|
return { templateDir };
|
|
923
919
|
}
|
|
@@ -932,8 +928,6 @@ async function runInit(rawOptions) {
|
|
|
932
928
|
|
|
933
929
|
if (options.yes) {
|
|
934
930
|
options.template ??= "node-lib";
|
|
935
|
-
options.projectName ??= "my-project";
|
|
936
|
-
options.target ??= options.projectName;
|
|
937
931
|
} else {
|
|
938
932
|
options = await promptForMissing(options);
|
|
939
933
|
}
|
|
@@ -948,9 +942,11 @@ async function runInit(rawOptions) {
|
|
|
948
942
|
const schema = await loadProfileSchema(packageRoot);
|
|
949
943
|
const profile = await resolveProfileForInit(packageRoot, options, schema);
|
|
950
944
|
|
|
951
|
-
const
|
|
945
|
+
const targetPath = path.resolve(process.cwd());
|
|
946
|
+
const inferredProjectName = path.basename(targetPath);
|
|
947
|
+
const projectName =
|
|
948
|
+
inferredProjectName && inferredProjectName !== path.sep ? inferredProjectName : "my-project";
|
|
952
949
|
const packageName = sanitizePackageName(projectName);
|
|
953
|
-
const targetPath = path.resolve(process.cwd(), options.target ?? projectName);
|
|
954
950
|
|
|
955
951
|
await ensureTargetReady(targetPath, options.force);
|
|
956
952
|
|
|
@@ -975,7 +971,6 @@ async function runInit(rawOptions) {
|
|
|
975
971
|
|
|
976
972
|
console.log(`Project created at ${targetPath}`);
|
|
977
973
|
console.log("Next steps:");
|
|
978
|
-
console.log(` cd ${targetPath}`);
|
|
979
974
|
console.log(" npm run check");
|
|
980
975
|
}
|
|
981
976
|
|
package/package.json
CHANGED
|
@@ -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
|
+
};
|