Skip to content

Commit

Permalink
initial Invoicing impl (#10)
Browse files Browse the repository at this point in the history
# initial Invoicing impl 

## Change Justification

Add the invoicing feature.

## Description

- Implement the InvoiceHandler and associated functionality. 
- update docs
- v0.2.0
  • Loading branch information
jkdmyrs authored Sep 12, 2023
1 parent 3999735 commit 8f2e8f6
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 28 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ _**Note:** Add `--allow-run --allow-read --allow-write` to skip security prompts
### Create your first punch

```
deno run https://deno.land/x/git_timeclock@v0.1.3-alpha/mod.ts <username>
deno run https://deno.land/x/git_timeclock@v0.2.0-alpha/mod.ts <username:string>
```

### End a shift

```
deno run https://deno.land/x/git_timeclock@v0.1.3-alpha/mod.ts <username> --end
deno run https://deno.land/x/git_timeclock@v0.2.0-alpha/mod.ts <username:string> <rate:decimal> --end
```

### Create an Invoice

```
deno run https://deno.land/x/git_timeclock@v0.2.0-alpha/mod.ts <username> --invoice <invoicee:string> <company:string> <rate:decimal>
```
88 changes: 87 additions & 1 deletion src/app/Invoice/InvoiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,91 @@
import { IInvoiceHandler } from "../../domain/Invoice/IInvoiceHandler.ts";
import { Invoice } from "../../domain/Invoice/Invoice.ts";
import { Shift } from "../../domain/Shift/Shift.ts";
import { getFilesNamesInDirectory, getNonEmptyDirectoriesAsync } from "../../infra/IO/Files.ts";
import { Dictionary } from "../../infra/Types/Types.ts";
import * as FileManager from "../../infra/IO/Files.ts";
import { executeShellCommandAsync } from "../../infra/IO/Shell.ts";
import { parse } from "https://deno.land/std@0.201.0/datetime/mod.ts";

export class InvoiceHandler implements IInvoiceHandler {

public async createInvoiceAsync(invoicee: string, companyName: string, rate: number): Promise<void> {
const userDirs = await getNonEmptyDirectoriesAsync('./.timeclock/shifts');
const invoiceData: Dictionary<Shift[]> = {};

for(const userDir of userDirs) {
const splitDir = userDir.split('/');
const userName = splitDir[splitDir.length-1];
invoiceData[userName] = await this.getUserShiftsAsync(userName, userDir, rate);
}
const invoice = new Invoice(invoiceData, invoicee, companyName, new Date());

await FileManager.createDirectoryAsync(invoice.invoiceDir);
await this.writeInvoiceFile(invoice);

(await executeShellCommandAsync("git", ["commit", "-m", `\"TIMECLOCK INVOICE - ${invoice.invoiceDate.toISOString().split('T')[0]}\"`])).verifyZeroReturnCode();
}

private async getUserShiftsAsync(userName: string, userDir: string, rate: number): Promise<Shift[]> {
const shifts: Shift[] = [];
const filenames = await getFilesNamesInDirectory(userDir);
for(const filename of filenames) {
const shiftContent: string = await Deno.readTextFile(`${userDir}/${filename}`);
const splitShift = shiftContent.split('_');
const shiftLength: number = Number.parseFloat(splitShift[0]);
const shiftDate: Date = parse(splitShift[1], 'yyyy-MM-dd');
const shift = new Shift(userName, shiftLength, filename, shiftDate, rate);
shifts.push(shift);
await Deno.remove(shift.shiftFilePath);
(await executeShellCommandAsync("git", ["add", shift.shiftFilePath])).verifyZeroReturnCode();
}
return shifts;
}

private async writeInvoiceFile(invoice: Invoice): Promise<void> {
let fileLines: string[] = [];

fileLines.push('---------------\n');
fileLines.push(`${invoice.company} Invoice\n`);
fileLines.push(`Bill to: ${invoice.invoicee}\n`);
fileLines.push('---------------\n');
fileLines.push('\n');
fileLines.push('---------------\n');
fileLines.push('User Invoices\n');
fileLines.push('---------------\n');
fileLines.push('\n');
for(const user in invoice.userInvoices) {
const [shifts, hours, cost] = invoice.userInvoices[user];
const userLines = this.createUserInvoiceLines(user, shifts, hours, cost);
fileLines = fileLines.concat(userLines);
fileLines.push('\n');
fileLines.push('-----\n');
fileLines.push('\n');
}
fileLines.push('\n');
fileLines.push('---------------\n');
fileLines.push('Totals\n');
fileLines.push('---------------\n');
fileLines.push(`Total Hours: ${invoice.totalHours}\n`);
fileLines.push(`Total Amount Due: ${invoice.amountDue}\n`);

for(const line of fileLines) {
await Deno.writeTextFile(invoice.invoiceFilePath, line, { append: true });
}

(await executeShellCommandAsync("git", ["add", invoice.invoiceFilePath])).verifyZeroReturnCode();
}

private createUserInvoiceLines(user: string, shifts: Shift[], hours: number, cost: number): string[] {
const lines: string[] = [];

lines.push(`User: ${user}\n`);
lines.push('Shifts:\n');
lines.push(' Date|Hours\n');
for(const shift of shifts) {
lines.push(` ${shift.date.toISOString().split('T')[0]}|${shift.diffHours}'\n`);
}
lines.push(`Hours: ${hours}\n`);
lines.push(`Amount Due: ${cost}\n`);
return lines;
}
}
11 changes: 4 additions & 7 deletions src/app/Punch/PunchHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@ import { Shift } from "../../domain/Shift/Shift.ts";
import { ShiftHandler } from "../Shift/ShiftHandler.ts";

export class PunchHandler implements IPunchHandler {
constructor() {
}

async createPunchAsync(punch: Punch): Promise<void> {
async createPunchAsync(punch: Punch, rate: number): Promise<void> {
if (punch.type === PunchType.Sart) {
await this.createStartPunchAsync(punch);
} else {
await this.createEndPunchAsync(punch);
await this.createEndPunchAsync(punch, rate);
}
await this.commitPunchAsync(punch);
}
Expand All @@ -30,10 +27,10 @@ export class PunchHandler implements IPunchHandler {
(await executeShellCommandAsync("git", ["add", punch.punchFilePath])).verifyZeroReturnCode();
}

private async createEndPunchAsync(punch: Punch) {
private async createEndPunchAsync(punch: Punch, rate: number) {
const nowUtcMs = Date.now().toString();
const punchStartUtcMs = await Deno.readTextFile(punch.punchFilePath);
const shift: Shift = new Shift(punch.user, punchStartUtcMs, nowUtcMs);
const shift: Shift = Shift.createFromPunchTimes(punch.user, punchStartUtcMs, nowUtcMs, new Date(), rate);

await Deno.remove(punch.punchFilePath);
(await executeShellCommandAsync("git", ["add", punch.punchFilePath])).verifyZeroReturnCode();
Expand Down
3 changes: 1 addition & 2 deletions src/app/Shift/ShiftHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import * as FileManager from "../../infra/IO/Files.ts";
import { executeShellCommandAsync } from "../../infra/IO/Shell.ts";

export class ShiftHandler implements IShiftHandler {

public async createShiftAsync(shift: Shift): Promise<void> {
await FileManager.createDirectoryAsync(shift.shiftDir);
await Deno.writeTextFile(shift.shiftFilePath, shift.diffHours.toString());
await Deno.writeTextFile(shift.shiftFilePath, `${shift.diffHours.toString()}_${new Date().toISOString().split('T')[0]}`);
(await executeShellCommandAsync("git", ["add", shift.shiftFilePath])).verifyZeroReturnCode();
}
}
28 changes: 21 additions & 7 deletions src/app/timeclock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { Punch } from "../domain/Punch/Punch.ts";
import { PunchType } from "../domain/Punch/PunchType.ts";
import { executeShellCommandAsync } from "../infra/IO/Shell.ts";
import { PunchHandler } from "./Punch/PunchHandler.ts";
import { InvoiceHandler } from "./Invoice/InvoiceHandler.ts";

export class TimeClock {
private _punchHandler = new PunchHandler();
private _invoiceHandler = new InvoiceHandler();

private async validateCleanWorkingTreeAsync() {
const statusOutput = (await executeShellCommandAsync("git", ["status"])).verifyZeroReturnCode();
Expand All @@ -14,13 +16,25 @@ export class TimeClock {
}

public async main() {
const user: string = Deno.args[0];
const isEndPunch: boolean = Deno.args.some(x => x === "--end");
const punchType: PunchType = isEndPunch ? PunchType.End : PunchType.Sart;

const punch = new Punch(punchType, user);

await this.validateCleanWorkingTreeAsync();
await this._punchHandler.createPunchAsync(punch);
const isInvoice: boolean = Deno.args.some(x => x === "--invoice");

if (isInvoice) {
const args = Deno.args.filter(x => !x.startsWith('--'));
const invoicee: string = args[0];
const company: string = args[1];
const rate: number = Number.parseFloat(args[2]);
await this._invoiceHandler.createInvoiceAsync(invoicee, company, rate);
}
else {
const args = Deno.args.filter(x => !x.startsWith('--'));
const user: string = args[0];
const isEndPunch: boolean = Deno.args.some(x => x === "--end");
const rate: number = isEndPunch ? Number.parseFloat(args[1]) : 0;
const punchType: PunchType = isEndPunch ? PunchType.End : PunchType.Sart;
const punch = new Punch(punchType, user);
await this._punchHandler.createPunchAsync(punch, rate);
}

}
}
2 changes: 1 addition & 1 deletion src/domain/Invoice/IInvoiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export interface IInvoiceHandler {

createInvoiceAsync(invoicee: string, companyName: string, rate: number): Promise<void>;
}
41 changes: 39 additions & 2 deletions src/domain/Invoice/Invoice.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
export class Invoice {

import { Dictionary } from "../../infra/Types/Types.ts";
import { Shift } from "../Shift/Shift.ts";
import { sumOf } from "https://deno.land/std@0.201.0/collections/mod.ts";
import { toFixed } from "https://deno.land/x/math@v1.1.0/to_fixed.ts";

export class Invoice{
userInvoices: Dictionary<[shifts: Shift[], totalHours: number, totalCost: number]>;
totalHours: number;
amountDue: number;
invoiceDir: string;
invoiceFilePath: string;
invoicee: string;
company: string;
invoiceDate: Date;

constructor(shifts: Dictionary<Shift[]>, invoicee: string, company: string, invoiceDate: Date) {
this.userInvoices = {};
for (const user in shifts) {
const userShifts: Shift[] = shifts[user];
const hours: number = sumOf(userShifts, x => x.diffHours);
const cost: number = sumOf(userShifts, x => x.amountDue)
this.userInvoices[user] = [userShifts, hours, cost];
}

this.amountDue = 0;
this.totalHours = 0;
for(const user in this.userInvoices) {
const [_, hours, amountDue] = this.userInvoices[user];
this.totalHours+=hours;
this.amountDue+=amountDue;
}
this.invoiceDir = "./.timeclock/invoices";
this.invoiceFilePath = `${this.invoiceDir}/${invoiceDate.toISOString().split('T')[0]}`;
this.invoicee = invoicee;
this.company = company;
this.invoiceDate = invoiceDate;
this.totalHours = Number.parseFloat(toFixed(this.totalHours, 2));
this.amountDue = Number.parseFloat(toFixed(this.amountDue, 2));
}
}
2 changes: 1 addition & 1 deletion src/domain/Punch/IPunchHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Punch } from "./Punch.ts";

export interface IPunchHandler {
createPunchAsync(punch: Punch): Promise<void>;
createPunchAsync(punch: Punch, rate: number): Promise<void>;
}
19 changes: 15 additions & 4 deletions src/domain/Shift/Shift.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import * as DomainConstants from "../Constants.ts";
import { toFixed } from "https://deno.land/x/math@v1.1.0/to_fixed.ts";

export class Shift {
user: string;
date: Date;
diffHours: number
shiftDir: string;
shiftFilePath: string;
amountDue: number;

constructor(user: string, punchStartUtcMs: string, punchEndUtcMs: string) {
this.user = user;
public static createFromPunchTimes(user: string, punchStartUtcMs: string, punchEndUtcMs: string, date: Date, rate: number) {
const diffMs: number = Number.parseInt(punchEndUtcMs) - Number.parseInt(punchStartUtcMs);
this.diffHours = diffMs/DomainConstants.ONE_HOUR_IN_MS;
const diffHours = diffMs/DomainConstants.ONE_HOUR_IN_MS;
const fileName = `shift_${crypto.randomUUID()}`;
return new Shift(user, diffHours, fileName, date, rate);
}

constructor(user: string, diffHours: number, filename: string, date: Date, rate: number) {
this.user = user;
this.diffHours = Number.parseFloat(toFixed(diffHours, 2));
this.shiftDir = `./.timeclock/shifts/${user}`;
this.shiftFilePath = `${this.shiftDir}/shift_${crypto.randomUUID()}`;
this.shiftFilePath = `${this.shiftDir}/${filename}`;
this.date = date;
this.amountDue = Number.parseFloat(toFixed(this.diffHours * rate, 2));
}
}
28 changes: 27 additions & 1 deletion src/infra/IO/Files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {exists} from "https://deno.land/std@0.201.0/fs/mod.ts";
import { join } from "https://deno.land/std@0.201.0/path/posix.ts";

export async function createDirectoryAsync(path: string) {
const dirExists = await exists(path);
Expand All @@ -7,4 +8,29 @@ export async function createDirectoryAsync(path: string) {
recursive: true
});
}
}
}

export async function getNonEmptyDirectoriesAsync(path: string): Promise<string[]> {
const dirs: string[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isDirectory) {
for await (const subEntry of Deno.readDir(join(path, dirEntry.name))) {
if (subEntry.isFile) {
dirs.push(join(path, dirEntry.name));
break;
}
}
}
}
return dirs;
}

export async function getFilesNamesInDirectory(path: string): Promise<string[]> {
const files: string[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isFile) {
files.push(dirEntry.name)
}
}
return files;
}
3 changes: 3 additions & 0 deletions src/infra/Types/Types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Dictionary<T> {
[key: string]: T;
}

0 comments on commit 8f2e8f6

Please sign in to comment.