Intro
This guide will show you how to install and configure a package in your project.
Prerequisitesβ
The following tools are required:
- Node.js
>=22.0 - SvelteKit is recommended to be used (any version compatible with Node version from above).
Installingβ
You can simply install by using the following command:
npm install <packageName>
All the packages can be found here.
Adding to your projectβ
You can implement the package in your project however you like it but as of today CCS only supports SvelteKit
You should have the following (minimum) folderstructure and files in your project:
βββ src
β βββ lib
β βββ addons
β βββ registery.server.ts
But it is recommended to have the following:
βββ src
β βββ lib
β βββ addons
β βββ registery.server.ts
β βββcomponents
β βββ cards.svelte
β βββserver
β βββ store.ts
β βββ routes
β βββ api
β βββ auth
β βββ +server.ts
β βββ list
β βββ +server.ts
β βββ run
β βββ +server.ts
registery.server.tsβ
This is where you can add your addons to the registry.
// registry.server.ts
import moneybirdWebAddon, {type ife as IFEmoneybirdWebAddon} from 'ccs-moneybird-api-addon';
import loggingAddon, {type ife as IFEloggingAddon} from 'ccs-logging-addon';
// β¦other importsβ¦
export const ADDON_REGISTRY = {
'moneybird-api': moneybirdWebAddon,
// 'logging': loggingAddon
// β¦
} as const;
export type AddOnName = keyof typeof ADDON_REGISTRY;
export function getAddOn<Name extends AddOnName>(name: Name) {
const addon = ADDON_REGISTRY[name];
if (!addon) throw new Error(`AddOn "${name}" does not exist.`);
return addon;
}
// List of addons
export function listAddons(): AddOnName[] {
return Object.keys(ADDON_REGISTRY) as AddOnName[];
}
export type AddonIFE = {
'moneybird-api': typeof IFEmoneybirdWebAddon,
'logging': typeof IFEloggingAddon
};
store.tsβ
The store is used to store the addon data like credentials and settings, but you can of course use your method.
// src/lib/server/store.ts
export const sessionStore = new Map<string, any>();
cards.svelteβ
This is a SvelteKit component that is used to display the addon cards so that it can be used as a visual representation of the addons within your project.
// src/lib/components/cards.svelte
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
export let visible: boolean = false;
export let message: string = '';
export let type: string = '';
export let title: string = '';
export let fields: any = {};
export let image: string = '';
export let version: string = '';
export let name: string = '';
let filled_fields: any = {};
export let sessionIdCookie: string = '';
// Get cookie (if exists) and fill filled_fields
// Check if document is loaded
if (sessionIdCookie !== '') {
retrieveAddonAuth(sessionIdCookie);
} else {
console.log("no sessionIdCookie");
}
async function retrieveAddonAuth(sessionIdCookie: string) {
const res = await fetch(`/api/addons/auth?sessionId=${sessionIdCookie}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const response = await res.json();
filled_fields = response.data.auth;
}
async function saveAddonAuth() {
const sessionId = uuidv4();
document.cookie = `sessionId=${sessionId}; SameSite=Lax; Secure; Path=/; Max-Age=14400`;
// build up the auth object from fields
const authPayload = Object.fromEntries(
// assume fields is a string[] of keys
fields.map((k) => [k, filled_fields[k] || ''])
);
const body = JSON.stringify({
addon: name,
auth: authPayload,
sessionId: sessionId
});
const res = await fetch('/api/addons/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
if (!res.ok) {
console.error('connect error', res.status);
return;
}
console.log(await res.json());
}
</script>
{#if visible}
<div
class="card bg-primary-500 bg-indigo-500 text-white border-surface-200-800 card-hover divide-surface-200-800 --color-primary-500 block h-full max-w-md divide-y overflow-hidden border-[1px]"
>
<!-- Header -->
<header>
<img src={image} class="aspect-[21/9] w-full " alt="banner" />
</header>
<!-- Main -->
<article class="space-y-4 p-4">
<div>
<h2 class="h6">{type}</h2>
<h3 class="h3 text-xl font-bold">{title}</h3>
</div>
<p class="opacity-60">
{message}
</p>
<hr class="hr border-t-2" />
<form class="space-y-6" on:submit|preventDefault={saveAddonAuth} method="POST">
{#each Object.entries(fields) as [key, value]}
<div>
<label for={key}>{value}</label>
<input
bind:value={filled_fields[(key, value)]}
type="text"
name={key}
id={key}
required
class="block w-full rounded-md bg-white text-black px-3 py-1.5 text-base outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
/>
</div>
{/each}
<div>
<button
type="submit"
class="flex w-full justify-center rounded-md bg-orange-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>Save AddOn Auth</button
>
</div>
</form>
</article>
<!-- Footer -->
<footer class="flex items-center justify-between gap-4 p-4">
<small class="opacity-60">AddOn: <b>{name}</b></small>
<small class="opacity-60">Version: <b>{version}</b></small>
</footer>
</div>
{/if}
Auth endpointβ
This internal endpoint is used to authorize the addon. For example making a connection to the server of the platform that the addon works with.
// src/routes/api/addons/auth/+server.ts
import type { RequestHandler } from '@sveltejs/kit';
import { sessionStore } from '$lib/server/store'
import {
type AddOnName,
} from '$lib/addons/registry.server';
export const GET: RequestHandler = async ({ url }) => {
const sessionId = url.searchParams.get('sessionId');
const data = sessionId ? sessionStore.get(sessionId) : null;
return new Response(JSON.stringify({ data }), {
headers: { 'Content-Type': 'application/json' }
});
};
export const POST: RequestHandler = async ({ request }) => {
const { addon: name, auth, sessionId } = await request.json() as { addon: string; auth: unknown };
const addOnName = name as AddOnName;
sessionStore.set(sessionId, {addOnName, auth});
return new Response(
JSON.stringify({ success: true }),
{
status: 200,
headers: { 'content-type': 'application/json' }
}
);
};
List endpointβ
This is the endpoint used to list the addons that are installed.
// src/routes/api/addons/list/+server.ts
import type { RequestHandler } from '@sveltejs/kit';
import {
ADDON_REGISTRY,
type AddOnName
} from '$lib/addons/registry.server';
// Force TS to see each entry as a [AddOnName, AddOn] tuple
type RegistryEntry = [
AddOnName,
typeof ADDON_REGISTRY[AddOnName]
];
export const GET: RequestHandler = () => {
const entries = Object.entries(ADDON_REGISTRY) as RegistryEntry[];
const meta = Object.fromEntries(
entries.map(([key, addon]) => {
return [
key,
{
name: addon.name,
name_friendly: addon.name_friendly,
version: addon.version,
type: addon.type,
sub_type: addon.sub_type,
map_version: addon.map_version,
auth_fields: addon.auth_fields,
}
];
})
);
return new Response(JSON.stringify(meta), {
headers: { 'content-type': 'application/json' }
});
};
Run endpointβ
The run ednpoint can be used for interacting with the addons but again you can implement it however you like it. Example usage:
// src/routes/api/addons/run/+server.ts
import type { RequestHandler } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { json, error } from '@sveltejs/kit';
import { sessionStore } from '$lib/server/store';
import {
getAddOn
} from '$lib/addons/registry.server';
let token: string = '';
let administration_id: string = '';
export const POST: RequestHandler = async ({ request }) => {
// 1) Grab all of your sessions from the in-memory store
const sessions = Array.from(sessionStore.values());
// 2) For each session, if it's fedex-web, call the async helper
for (const session of sessions) {
if (session.pluginName === 'moneybird-api') {
token = session.auth.token;
administration_id = session.auth.administration_id;
}
}
// 1. Grab all uploaded CSVs from the multipart/form-data POST
const form = await request.formData();
const uploads = form.getAll('files');
if (!uploads.length) {
throw error(400, 'No files uploaded');
}
const moneybird_results = [];
for (const fileCandidate of uploads) {
if (!(fileCandidate instanceof File)) {
// skip any non-File entries
continue;
}
const file = fileCandidate;
const text = await file.text();
const jsonData = csvToJson(text);
const mappedInvoiceData = mapFields(jsonData);
// console.log("mappedInvoiceData: ", mappedInvoiceData);
const addOn = getAddOn('moneybird-api').init();
for (const mappedInvoice of mappedInvoiceData) {
const parsedInvoice = await addOn.mapPurchaseInvoiceData(mappedInvoice, token, administration_id, true);
moneybird_results.push(await addOn.createPurchaseInvoice(parsedInvoice.data, administration_id, token));
}
}
// 4. Return the parsed data
return json({
success: true,
files: moneybird_results
});
};
/**
* Parse a CSV string into an array of objects, correctly handling
* quoted fields and stripping their wrapping quotes.
*
* @param {string} csv The raw CSV text
* @param {string} [sep] Field delimiter (default: comma)
* @returns {Object[]}
*/
function csvToJson(csv, sep = ',') {
const splitter = new RegExp(`${sep}(?=(?:[^"]*"[^"]*")*[^"]*$)`);
const lines = csv.trim().split(/\r?\n/);
// 1) pull off header row, strip quotes, then replace spaces/hyphens with underscores
const headers = lines
.shift()!
.split(splitter)
.map(h =>
h
.trim()
.replace(/^"(.*)"$/, '$1') // strip wrapping quotes
.replace(/[\s\-\\/]+/g, '_') // spaces or hyphens β _
.toLowerCase() // lowercase
);
// 2) parse each data row the same way (strip quotes)
const rows = lines.map(line =>
line.split(splitter).map(cell =>
cell.trim().replace(/^"(.*)"$/, '$1')
)
);
// 3) zip headers β values
return rows.map(values => {
const obj: Record<string, string> = {};
headers.forEach((key, i) => {
obj[key] = values[i] ?? '';
});
return obj;
});
}
function mapFields(invoiceData) {
//
const purchaseInvoices = [];
// First group the data by invoice number
const groupedData = invoiceData.reduce((acc, curr) => {
const invoiceNumber = curr["fedex_factuurnummer"];
if (!acc[invoiceNumber]) {
acc[invoiceNumber] = [];
}
acc[invoiceNumber].push(curr);
return acc;
}, {});
// Loop through the grouped data and create the purchase invoice object
// console.log("groupedData: ", groupedData);
for (const invoiceNumber in groupedData) {
const invoiceData = groupedData[invoiceNumber];
const purchaseInvoice = {
invoice_from: "",
invoice_type: "",
invoice_client_name: "",
invoice_client_company: "",
invoice_client_billing_auto: false,
invoice_client_billing_period: "",
invoice_reference: "",
invoice_date: "",
invoice_due_date: "",
invoice_items: []
};
purchaseInvoice.invoice_from = "FedEx Express Netherlands B.V.";
purchaseInvoice.invoice_type = "purchase_invoice";
purchaseInvoice.invoice_client_name = "Elwin Hammer";
purchaseInvoice.invoice_client_company = "InstantPack V.O.F.";
purchaseInvoice.invoice_client_billing_auto = false;
purchaseInvoice.invoice_client_billing_period = "";
purchaseInvoice.invoice_reference = invoiceData[0]["fedex_factuurnummer"];
purchaseInvoice.invoice_date = invoiceData[0]["factuurdatum"];
purchaseInvoice.invoice_due_date = invoiceData[0]["vervaldatum"];
purchaseInvoice.invoice_items = invoiceData.map((item) => ({
item_description: item["bedrijfsnaam_afzender"] + " - " + item["type_factuur"]+ " - " + item["svcpkg_label"] + " - " + item["land_gebied_adres_ontvanger"] + " - " + item["luchtvrachtbriefnummer"],
item_quantity: 1,
item_price: item["totale_bedrag_luchtvrachtbrief"],
item_tax_rate: item["land_gebied_adres_ontvanger"] === "US" || item["land_gebied_adres_ontvanger"] === "CA" ? "Btw vrijgesteld" : "21% btw", // Btw vrijgesteld only when country is US or CA
item_ledger_account: "Verzendkosten"
}));
purchaseInvoices.push(purchaseInvoice);
}
return purchaseInvoices;
}
Client side usageβ
Below is a simple example of how to use the API from the client side:
<script lang="ts">
import { browser } from '$app/environment';
import Cards from '$lib/components/cards.svelte';
import { onMount } from 'svelte';
export let data: {
addons: Record<
string,
{ name: string; name_friendly: string; version: string; authFields: string[] }
>;
};
let sessionIdCookie: string = '';
let loaded = false;
onMount(() => {
if (!browser) return;
const cookies = document.cookie;
if (cookies.includes('sessionId=')) {
sessionIdCookie = cookies.split('sessionId=')[1].split(';')[0];
}
const markReady = () => {
loaded = true;
window.removeEventListener('load', markReady);
};
// If the page is already fully loaded:
if (document.readyState === 'complete') {
markReady();
} else {
// Otherwise wait for the load event
window.addEventListener('load', markReady);
}
});
$: addonCards = Object.entries(data.addons).map(([key, meta]) => ({
visible: loaded,
message: meta.name,
title: meta.name_friendly,
fields: meta.auth_fields,
image: `${meta.name.split('-')[0]}.png`,
version: meta.version,
name: meta.name,
sessionIdCookie // β now carries the up-to-date cookie
}));
async function connectAddon(sessionIdCookie: string) {
// build up the auth object from fields
const authPayload = Object.fromEntries(
// assume fields is a string[] of keys
fields.map((k) => [k, filled_fields[k] || ''])
);
const body = JSON.stringify({
plugin: name,
auth: authPayload,
sessionId: sessionIdCookie
});
const res = await fetch('/api/addons/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
console.log('res: ', await res.json());
if (!res.ok) {
console.error('connect error', res.status);
return;
}
// console.log(await res.json());
const response = await res.json();
filled_fields = response.result;
}
let uploadedFiles: FileList;
async function uploadCsvFiles() {
if (!uploadedFiles?.length) return;
const form = new FormData();
// append each selected CSV under the same field name
for (const file of uploadedFiles) {
form.append('files', file);
}
const res = await fetch('/api/addons/run', {
method: 'POST',
body: form
});
const data = await res.json();
console.log('server response:', data);
}
</script>
<div class="grid h-screen grid-rows-[auto_1fr_auto]">
<!-- Header -->
<header class="sticky top-0 z-10 bg-blue-500/80 p-4 backdrop-blur-sm">
<h1 class="text-3xl font-bold">FedEx Invoice Parsing</h1>
</header>
<!-- Main -->
<main class="space-y-4 bg-gray-500 p-4">
<div class="h-auto p-4">
<div
class="card bg-primary-500 border-surface-200-800 card-hover divide-surface-200-800 --color-primary-500 block h-full max-w-md divide-y overflow-hidden border-[1px] bg-indigo-500 text-white"
>
<form
class="space-y-6"
on:submit|preventDefault={(e) => uploadCsvFiles(uploadedFiles)}
method="POST"
>
<div>
<!-- file upload -->
<label class="label">
<span class="label-text">File Input</span>
<input
class="flex w-full justify-center rounded-md bg-orange-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
type="file"
accept=".csv"
multiple
bind:files={uploadedFiles}
/>
</label>
<br />
<button
type="submit"
class="flex w-full justify-center rounded-md bg-orange-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>Upload CSV-files</button
>
</div>
</form>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<!-- Load components after the page is loaded /> -->
{#if !loaded}
<p>Loadingβ¦</p>
{:else}
{#each addonCards as card}
<div class="h-auto p-4">
<Cards {...card} />
</div>
{/each}
{/if}
</div>
</main>
<!-- Footer -->
<footer class="p-4">
<a href="https://github.com/Craft-Code-Systems/fedex-invoice-parsing">
<button type="button" class="chip preset-filled-surface-500 bg-gray-500 text-white"
>GitHub</button
>
</a>
</footer>
</div>