Function bodies 198 total
getApp function · typescript · L6-L14 (9 LOC)_api/index.ts
function getApp() {
if (!appPromise) {
appPromise = createServer().then(async (app) => {
await app.ready();
return app;
});
}
return appPromise;
}handler function · typescript · L16-L26 (11 LOC)_api/index.ts
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
const app = await getApp();
app.server.emit("request", req, res);
} catch (err: any) {
console.error("[handler] Fatal error:", err);
res.statusCode = 500;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: err?.message }));
}
}createServer function · typescript · L15-L90 (76 LOC)server/app.ts
export async function createServer() {
const app = Fastify({
logger: env.NODE_ENV === "development",
});
await app.register(cors, {
origin: (origin, callback) => {
if (!origin) {
callback(null, true);
return;
}
const allowedOrigins = env.CORS_ORIGIN.split(",").map((entry) => entry.trim());
callback(null, allowedOrigins.includes(origin));
},
credentials: false,
});
// Override JSON parser to capture raw body for Stripe webhook verification
app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
try {
(req as any).rawBody = body;
done(null, JSON.parse(body as string));
} catch (err: any) {
done(err, undefined);
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) {
request.log.warn({ issues: error.issues }, "Validation error");
reply.status(400).send({
message: "Invalid request payload",
start function · typescript · L5-L38 (34 LOC)server/index.ts
async function start() {
const app = await createServer();
await app.listen({ host: env.HOST, port: env.PORT });
// Periodic Gmail sync every 5 minutes
if (env.DATABASE_URL) {
setInterval(async () => {
try {
const { syncAllGmailInboxes } = await import("./services/gmail-sync");
const result = await syncAllGmailInboxes();
if (result.total > 0) {
console.log(`[periodic-sync] Synced ${result.total} new documents`);
}
} catch (error) {
console.error("[periodic-sync] Failed:", error);
}
}, 5 * 60 * 1000);
console.log("[startup] Periodic Gmail sync enabled (every 5 min)");
// Monthly delivery check once per hour (local dev; Vercel uses cron)
setInterval(async () => {
try {
const { checkAndRunMonthlyDeliveries } = await import("./services/monthly-delivery");
const result = await checkAndRunMonthlyDeliveries();
if (result.delivered > 0) {
console.log(`[monthgetAccountantEmail function · typescript · L25-L35 (11 LOC)server/routes/accountant.ts
async function getAccountantEmail(request: FastifyRequest): Promise<string> {
const auth = request.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
throw Object.assign(new Error("Unauthorized"), { statusCode: 401 });
}
const result = verifyAccountantToken(auth.slice(7));
if (!result) {
throw Object.assign(new Error("Invalid or expired token"), { statusCode: 401 });
}
return result.email;
}assertAccountantAccessToBusiness function · typescript · L37-L43 (7 LOC)server/routes/accountant.ts
async function assertAccountantAccessToBusiness(email: string, businessId: string): Promise<void> {
const clients = await store.getBusinessesForAccountant(email);
const hasAccess = clients.some((c: any) => c.id === businessId);
if (!hasAccess) {
throw Object.assign(new Error("Access denied to this business"), { statusCode: 403 });
}
}registerAccountantRoutes function · typescript · L47-L246 (200 LOC)server/routes/accountant.ts
export async function registerAccountantRoutes(app: FastifyInstance): Promise<void> {
// Send magic link
app.post("/accountant/auth/send-magic-link", async (request) => {
const { email } = magicLinkSchema.parse(request.body);
await sendMagicLinkEmail(email);
return { ok: true, message: "אם הכתובת קיימת במערכת, נשלח אליך קישור כניסה." };
});
// Verify magic link token → return session JWT
app.post("/accountant/auth/verify", async (request) => {
const { token } = verifySchema.parse(request.body);
const result = verifyMagicLinkToken(token);
if (!result) {
throw Object.assign(new Error("קישור לא תקין או שפג תוקפו"), { statusCode: 401 });
}
const exists = await store.accountantEmailExists(result.email);
if (!exists) {
throw Object.assign(new Error("הכתובת לא נמצאה במערכת"), { statusCode: 404 });
}
const sessionToken = createAccountantToken(result.email);
return {
ok: true,
token: sessionToken,
email: Source: Repobility analyzer · https://repobility.com
registerBillingRoutes function · typescript · L18-L128 (111 LOC)server/routes/billing.ts
export async function registerBillingRoutes(app: FastifyInstance): Promise<void> {
// Get billing status
app.get<{ Params: { businessId: string } }>(
"/billing/:businessId/status",
async (request) => {
const { businessId } = request.params;
// If Stripe not configured, everything is unlocked (dev mode)
if (!isBillingEnabled()) {
return { onboardingPaid: true, subscriptionStatus: "active", billingEnabled: false };
}
const billing = await store.getBusinessBilling(businessId);
return {
onboardingPaid: billing.onboardingPaid,
subscriptionStatus: billing.subscriptionStatus,
billingEnabled: true,
};
},
);
// Create checkout session (onboarding $10 + monthly $5 subscription)
app.post<{ Params: { businessId: string } }>(
"/billing/:businessId/create-checkout",
async (request, reply) => {
if (!isBillingEnabled()) {
reply.code(400);
return { error: "Billing not configuregisterStripeWebhook function · typescript · L131-L233 (103 LOC)server/routes/billing.ts
export async function registerStripeWebhook(app: FastifyInstance): Promise<void> {
app.post(
"/webhooks/stripe",
{
config: { rawBody: true },
},
async (request, reply) => {
if (!isBillingEnabled() || !env.STRIPE_WEBHOOK_SECRET) {
reply.code(400);
return { error: "Stripe webhook not configured" };
}
const stripe = getStripe();
const sig = request.headers["stripe-signature"] as string;
const rawBody = (request as any).rawBody;
if (!rawBody || !sig) {
reply.code(400);
return { error: "Missing signature or body" };
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET);
} catch (err: any) {
console.error("[stripe] Webhook signature verification failed:", err.message);
reply.code(400);
return { error: "Invalid signature" };
}
console.log(`[stripe] Received event: ${event.tregisterDashboardRoutes function · typescript · L43-L206 (164 LOC)server/routes/dashboard.ts
export async function registerDashboardRoutes(app: FastifyInstance): Promise<void> {
app.get("/dashboard/:businessId/summary", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
return store.getDashboardSummary(businessId);
});
app.get("/dashboard/:businessId/documents", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
const query = documentQuerySchema.parse(request.query);
return store.getDashboardDocuments(businessId, query.status);
});
app.get("/dashboard/:businessId/documents/:documentId", async (request) => {
const { businessId, documentId } = documentParamsSchema.parse(request.params);
return store.getDashboardDocumentDetail(businessId, documentId);
});
app.patch("/dashboard/:businessId/documents/:documentId", async (request) => {
const { businessId, documentId } = documentParamsSchema.parse(request.params);
const updates = updateDocumentSchema.parse(reregisterDeepScanRoutes function · typescript · L5-L175 (171 LOC)server/routes/deep-scan.ts
export async function registerDeepScanRoutes(app: FastifyInstance): Promise<void> {
// Start a deep scan for a business
app.post<{ Params: { businessId: string } }>(
"/deep-scan/:businessId/start",
async (request, reply) => {
if (!env.DATABASE_URL) {
reply.code(400);
return { error: "Deep scan requires a database connection" };
}
const { businessId } = request.params;
// Gate behind billing (if Stripe is configured)
if (env.STRIPE_SECRET_KEY) {
const billing = await store.getBusinessBilling(businessId);
if (!billing.onboardingPaid) {
reply.code(402);
return { error: "Payment required — activate your account first" };
}
}
// Check if there's already an active scan
const existing = await store.getActiveScanJob(businessId);
if (existing) {
return {
scanJobId: existing.id,
status: existing.status,
message: "A scan is already registerHealthRoutes function · typescript · L4-L85 (82 LOC)server/routes/health.ts
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
app.get("/health", async () => ({
ok: true,
timestamp: new Date().toISOString(),
}));
// Vercel Cron endpoint for monthly delivery
app.post("/cron/monthly-delivery", async (request, reply) => {
const authHeader = request.headers.authorization;
if (env.CRON_SECRET && authHeader !== `Bearer ${env.CRON_SECRET}`) {
reply.code(401);
return { error: "Unauthorized" };
}
try {
const { checkAndRunMonthlyDeliveries } = await import("../services/monthly-delivery");
const result = await checkAndRunMonthlyDeliveries();
return { ok: true, ...result };
} catch (error) {
console.error("[cron] Monthly delivery failed:", error);
reply.code(500);
return { error: "Monthly delivery failed" };
}
});
// Vercel Cron endpoint for Gmail sync
app.post("/cron/gmail-sync", async (request, reply) => {
const authHeader = request.headersonboardingRedirect function · typescript · L29-L45 (17 LOC)server/routes/oauth.ts
function onboardingRedirect(params: {
status: "success" | "error";
provider: OAuthProvider;
businessId?: string;
message?: string;
}): string {
const query = new URLSearchParams();
query.set("oauth", params.status);
query.set("provider", params.provider);
if (params.businessId) {
query.set("businessId", params.businessId);
}
if (params.message) {
query.set("message", params.message);
}
return `${env.FRONTEND_BASE_URL}/onboarding?${query.toString()}`;
}registerOAuthRoutes function · typescript · L47-L130 (84 LOC)server/routes/oauth.ts
export async function registerOAuthRoutes(app: FastifyInstance): Promise<void> {
app.get("/oauth/:provider/start", async (request) => {
const { provider } = providerSchema.parse(request.params);
const { businessId } = startQuerySchema.parse(request.query);
store.getOnboardingState(businessId);
if (!isOAuthConfigured(provider, env)) {
throw new Error(`${provider} OAuth is not configured on the server`);
}
return {
provider,
authUrl: buildOAuthStartUrl(provider, businessId, env),
};
});
app.get("/oauth/:provider/callback", async (request, reply) => {
const { provider } = providerSchema.parse(request.params);
const query = callbackQuerySchema.parse(request.query);
if (query.error) {
return reply.redirect(
onboardingRedirect({
status: "error",
provider,
message: query.error_description ?? query.error,
}),
);
}
if (!query.code || !query.state) {
return rregisterOnboardingRoutes function · typescript · L29-L60 (32 LOC)server/routes/onboarding.ts
export async function registerOnboardingRoutes(app: FastifyInstance): Promise<void> {
app.post("/onboarding/start", async (request) => {
const body = startPayloadSchema.parse(request.body);
return store.startOnboarding(body);
});
app.get("/onboarding/state/:businessId", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
return store.getOnboardingState(businessId);
});
app.post("/onboarding/connect-inbox", async (request) => {
const body = connectInboxPayloadSchema.parse(request.body);
return store.connectInbox(body);
});
app.post("/onboarding/scan", async (request) => {
const body = scanPayloadSchema.parse(request.body);
// Sync real Gmail inboxes before building summary
const gmailInboxes = await store.getGmailInboxes(body.businessId);
for (const inbox of gmailInboxes) {
try {
await syncGmailInbox(inbox.id);
} catch (error) {
console.error(`[scan] Gmail sync faileRepobility · open methodology · https://repobility.com/research/
registerSettingsRoutes function · typescript · L32-L60 (29 LOC)server/routes/settings.ts
export async function registerSettingsRoutes(app: FastifyInstance): Promise<void> {
app.get("/settings/:businessId", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
return store.getSettings(businessId);
});
app.patch("/settings/:businessId/account", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
const payload = accountPayloadSchema.parse(request.body);
return store.updateAccountSettings({
businessId,
...payload,
});
});
app.patch("/settings/:businessId/accountant", async (request) => {
const { businessId } = businessParamsSchema.parse(request.params);
const payload = accountantPayloadSchema.parse(request.body);
return store.updateAccountantSettings({
businessId,
...payload,
});
});
app.delete("/settings/:businessId/inboxes/:inboxId", async (request) => {
const { businessId, inboxId } = inboxParamsSchema.parse(request.paramregisterWhatsAppRoutes function · typescript · L39-L146 (108 LOC)server/routes/whatsapp.ts
export async function registerWhatsAppRoutes(app: FastifyInstance): Promise<void> {
// Connect — start Baileys session via bridge
app.post("/whatsapp/connect", async (request) => {
const { businessId } = connectSchema.parse(request.body);
if (!env.WHATSAPP_BRIDGE_URL) {
throw new Error("WhatsApp bridge is not configured (missing WHATSAPP_BRIDGE_URL)");
}
const session = await bridgeConnect(businessId);
return {
businessId,
provider: "baileys",
session,
};
});
// Get session status (QR code, connection state)
app.get("/whatsapp/session/:businessId", async (request) => {
const { businessId } = businessIdSchema.parse(request.params);
if (!env.WHATSAPP_BRIDGE_URL) {
return {
provider: "baileys",
status: "idle",
businessId,
mainPhoneE164: null,
qrDataUrl: null,
lastError: "WhatsApp bridge not configured",
connectedJid: null,
updatedAt: null,
};
createAccountantToken function · typescript · L18-L29 (12 LOC)server/services/accountant-auth.ts
export function createAccountantToken(email: string): string {
const payload: AccountantTokenPayload = {
email: email.toLowerCase(),
iat: Date.now(),
exp: Date.now() + TOKEN_EXPIRY_MS,
};
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", TOKEN_SECRET)
.update(payloadB64)
.digest("base64url");
return `${payloadB64}.${signature}`;
}createMagicLinkToken function · typescript · L59-L71 (13 LOC)server/services/accountant-auth.ts
export function createMagicLinkToken(email: string): string {
const payload = {
email: email.toLowerCase(),
iat: Date.now(),
exp: Date.now() + 15 * 60 * 1000, // 15 minutes
nonce: randomBytes(8).toString("hex"),
};
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", TOKEN_SECRET)
.update(payloadB64)
.digest("base64url");
return `${payloadB64}.${signature}`;
}getClient function · typescript · L7-L13 (7 LOC)server/services/ai.ts
function getClient(): Anthropic {
if (!client) {
if (!env.ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY not configured");
client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
}
return client;
}logUsage function · typescript · L21-L42 (22 LOC)server/services/ai.ts
async function logUsage(
businessId: string,
model: string,
operation: string,
usage: { input_tokens: number; output_tokens: number },
): Promise<void> {
try {
const isExpensive = model.includes("sonnet") || model.includes("opus");
const inputCostPerMTok = isExpensive ? 300 : 25;
const outputCostPerMTok = isExpensive ? 1500 : 125;
const costCents =
(usage.input_tokens * inputCostPerMTok + usage.output_tokens * outputCostPerMTok) / 1_000_000;
await pool.query(
`INSERT INTO ai_usage_log (business_id, model, operation, input_tokens, output_tokens, cost_cents)
VALUES ($1, $2, $3, $4, $5, $6)`,
[businessId, model, operation, usage.input_tokens, usage.output_tokens, costCents],
);
} catch (err) {
console.error("[ai] Failed to log usage:", err);
}
}buildExtractionPrompt function · typescript · L87-L108 (22 LOC)server/services/ai.ts
function buildExtractionPrompt(vendorMappings?: VendorCategoryMapping[]): string {
let prompt = EXTRACTION_SYSTEM_PROMPT_BASE;
if (vendorMappings && vendorMappings.length > 0) {
const examples = vendorMappings.slice(0, 20);
prompt += `\n\nIMPORTANT: The user has previously corrected categories for these vendors. Use these mappings when you encounter the same or similar vendor names:\n`;
for (const m of examples) {
prompt += `- "${m.vendorNameOriginal}" → ${m.category}\n`;
}
prompt += `\nIf you recognize a vendor that matches or is very similar to one of these, use the mapped category. For example, "Google LLC", "Google Israel", and "GOOGLE" should all map to the same category if any variant appears above.`;
const customCategories = [...new Set(
vendorMappings.map(m => m.category)
.filter(c => !BUILTIN_CATEGORIES.includes(c))
)];
if (customCategories.length > 0) {
prompt += `\nAdditional custom categories this business usesparseExtractedJson function · typescript · L110-L139 (30 LOC)server/services/ai.ts
function parseExtractedJson(response: Anthropic.Message): ExtractedInvoice {
const text =
response.content[0]?.type === "text" ? response.content[0].text : "";
const cleaned = text.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
try {
const parsed = JSON.parse(cleaned);
return {
vendorName: String(parsed.vendorName || "לא ידוע"),
amountCents: Math.round(Number(parsed.amountCents) || 0),
currency: String(parsed.currency || "ILS"),
vatCents: parsed.vatCents != null ? Math.round(Number(parsed.vatCents)) : null,
issuedAt: String(parsed.issuedAt || new Date().toISOString().slice(0, 10)),
type: String(parsed.type || "INVOICE").toUpperCase(),
category: String(parsed.category || "כללי"),
confidence: Math.min(1, Math.max(0, Number(parsed.confidence) || 0.5)),
};
} catch {
console.error("[ai] Failed to parse JSON response:", cleaned.slice(0, 200));
return {
vendorName: "לא ידוע",
amountCents: 0,Open data scored by Repobility · https://repobility.com
extractInvoiceFromText function · typescript · L143-L159 (17 LOC)server/services/ai.ts
export async function extractInvoiceFromText(
businessId: string,
rawText: string,
vendorMappings?: VendorCategoryMapping[],
): Promise<ExtractedInvoice> {
const model = env.AI_MODEL_CHEAP;
const response = await getClient().messages.create({
model,
max_tokens: 1024,
system: buildExtractionPrompt(vendorMappings),
messages: [
{ role: "user", content: `Extract invoice data from this email:\n\n${rawText.substring(0, 4000)}` },
],
});
await logUsage(businessId, model, "extract_text", response.usage);
return parseExtractedJson(response);
}extractInvoiceFromImage function · typescript · L163-L194 (32 LOC)server/services/ai.ts
export async function extractInvoiceFromImage(
businessId: string,
imageBase64: string,
mimeType: string,
modelOverride?: string,
vendorMappings?: VendorCategoryMapping[],
): Promise<ExtractedInvoice> {
const model = modelOverride ?? env.AI_MODEL_EXPENSIVE;
const mediaType = mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp";
const response = await getClient().messages.create({
model,
max_tokens: 1024,
system: buildExtractionPrompt(vendorMappings),
messages: [
{
role: "user",
content: [
{
type: "image",
source: { type: "base64", media_type: mediaType, data: imageBase64 },
},
{
type: "text",
text: "Extract invoice/receipt data from this image. Return JSON only.",
},
],
},
],
});
await logUsage(businessId, model, "extract_image", response.usage);
return parseExtractedJson(response);
}extractInvoiceFromPdf function · typescript · L198-L231 (34 LOC)server/services/ai.ts
export async function extractInvoiceFromPdf(
businessId: string,
pdfBase64: string,
modelOverride?: string,
vendorMappings?: VendorCategoryMapping[],
): Promise<ExtractedInvoice> {
const model = modelOverride ?? env.AI_MODEL_EXPENSIVE;
const response = await getClient().messages.create({
model,
max_tokens: 1024,
system: buildExtractionPrompt(vendorMappings),
messages: [
{
role: "user",
content: [
{
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: pdfBase64,
},
} as any,
{
type: "text",
text: "Extract invoice/receipt data from this PDF. Return JSON only.",
},
],
},
],
});
await logUsage(businessId, model, "extract_pdf", response.usage);
return parseExtractedJson(response);
}chatResponse function · typescript · L235-L283 (49 LOC)server/services/ai.ts
export async function chatResponse(
businessId: string,
userMessage: string,
recentMessages: Array<{ role: "user" | "assistant"; text: string }>,
context: {
businessName: string;
accountantName: string;
summary: any;
recentDocs: any[];
},
): Promise<string> {
const model = env.AI_MODEL_CHEAP;
const systemPrompt = `אתה "Amram AI", עוזר חכם לניהול חשבוניות והוצאות עבור עסקים ישראליים.
ענה בעברית בצורה תמציתית וידידותית (1-3 משפטים).
הקשר עסקי:
- שם העסק: ${context.businessName}
- רואה חשבון: ${context.accountantName}
- סיכום: סה"כ ${context.summary?.totals?.documents ?? 0} מסמכים, ${context.summary?.totals?.sent ?? 0} נשלחו, ${context.summary?.totals?.pending ?? 0} ממתינים
- סכום כולל: ₪${((context.summary?.totals?.amountCents ?? 0) / 100).toLocaleString("he-IL")}
- מסמכים אחרונים: ${JSON.stringify(context.recentDocs?.slice(0, 10) ?? [])}
כללים:
- ענה רק על סמך הנתונים שבהקשר. אל תמציא.
- אם אינך יודע, אמור "אין לי מספיק מידע לענות על זה."
- אם שואלים על getResend function · typescript · L6-L14 (9 LOC)server/services/email.ts
function getResend(): Resend {
if (!resend) {
if (!env.RESEND_API_KEY) {
throw new Error("RESEND_API_KEY is not configured");
}
resend = new Resend(env.RESEND_API_KEY);
}
return resend;
}formatDate function · typescript · L31-L37 (7 LOC)server/services/email.ts
function formatDate(dateIso: string): string {
return new Intl.DateTimeFormat("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(dateIso));
}buildDocumentTable function · typescript · L39-L66 (28 LOC)server/services/email.ts
function buildDocumentTable(documents: DocumentRow[]): string {
const rows = documents
.map(
(doc) =>
`<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eee;">${doc.vendor}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee;">${formatAmount(doc.amountCents, doc.currency)}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee;">${formatDate(doc.issuedAt)}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee;">${doc.category}</td>
</tr>`,
)
.join("\n");
return `
<table style="width:100%;border-collapse:collapse;font-family:Arial,sans-serif;direction:rtl;text-align:right;">
<thead>
<tr style="background:#f8f8f8;">
<th style="padding:10px 12px;border-bottom:2px solid #ddd;text-align:right;">ספק</th>
<th style="padding:10px 12px;border-bottom:2px solid #ddd;text-align:right;">סכום</th>
<th style="padding:10px 12px;border-bottom:2psendDocumentsToAccountant function · typescript · L68-L74 (7 LOC)server/services/email.ts
export async function sendDocumentsToAccountant(payload: {
accountantEmail: string;
accountantName: string;
businessName: string;
documents: DocumentRow[];
senderName?: string;
}): Promise<{ id: string }> {Want this analysis on your repo? https://repobility.com/scan/
gmailFetch function · typescript · L44-L53 (10 LOC)server/services/gmail-sync.ts
export async function gmailFetch(accessToken: string, path: string): Promise<any> {
const response = await fetch(`${GMAIL_API}/users/me${path}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gmail API ${path} failed (${response.status}): ${text}`);
}
return response.json();
}fetchRecentMessageIds function · typescript · L55-L64 (10 LOC)server/services/gmail-sync.ts
async function fetchRecentMessageIds(accessToken: string, maxResults: number): Promise<string[]> {
const query = encodeURIComponent(
"has:attachment OR subject:(חשבונית OR invoice OR receipt OR קבלה OR payment OR תשלום OR billing OR הזמנה)",
);
const data: GmailListResponse = await gmailFetch(
accessToken,
`/messages?maxResults=${maxResults}&q=${query}`,
);
return (data.messages ?? []).map((m) => m.id);
}fetchNewMessageIdsSinceHistory function · typescript · L66-L102 (37 LOC)server/services/gmail-sync.ts
async function fetchNewMessageIdsSinceHistory(
accessToken: string,
startHistoryId: string,
): Promise<string[]> {
const ids: string[] = [];
let pageToken: string | undefined;
try {
do {
const params = new URLSearchParams({
startHistoryId,
historyTypes: "messageAdded",
});
if (pageToken) params.set("pageToken", pageToken);
const data: GmailHistoryResponse = await gmailFetch(
accessToken,
`/history?${params.toString()}`,
);
for (const entry of data.history ?? []) {
for (const added of entry.messagesAdded ?? []) {
ids.push(added.message.id);
}
}
pageToken = data.nextPageToken;
} while (pageToken);
} catch (error: any) {
// If history ID is invalid/expired, fall back to full fetch
if (error.message?.includes("404") || error.message?.includes("historyId")) {
console.warn("Gmail historyId expired, falling back to full fetch");
return fetchRecentextractPlainText function · typescript · L127-L145 (19 LOC)server/services/gmail-sync.ts
function extractPlainText(message: GmailMessage): string | null {
if (message.payload.body?.data) {
return Buffer.from(message.payload.body.data, "base64url").toString("utf-8");
}
const textPart = message.payload.parts?.find((p) => p.mimeType === "text/plain");
if (textPart?.body?.data) {
return Buffer.from(textPart.body.data, "base64url").toString("utf-8");
}
// Check nested parts (multipart/alternative inside multipart/mixed)
for (const part of message.payload.parts ?? []) {
if (part.parts) {
const nested = part.parts.find((p: any) => p.mimeType === "text/plain");
if (nested?.body?.data) {
return Buffer.from(nested.body.data, "base64url").toString("utf-8");
}
}
}
return null;
}extractDocumentFromEmail function · typescript · L147-L167 (21 LOC)server/services/gmail-sync.ts
export function extractDocumentFromEmail(
message: GmailMessage,
inbox: { id: string; businessId: string },
): {
businessId: string;
inboxConnectionId: string;
source: string;
type: string;
status: string;
vendorName: string;
amountCents: number;
currency: string;
vatCents: number | null;
issuedAt: string;
confidence: number;
category: string | null;
rawText: string | null;
gmailMessageId: string;
attachments: AttachmentInfo[];
attachmentFilenames: string[];
} | null {detectVendorPatterns function · typescript · L8-L46 (39 LOC)server/services/missing-receipts.ts
export async function detectVendorPatterns(businessId: string): Promise<number> {
const vendors = await store.getVendorDocumentFrequency(businessId);
let patternsFound = 0;
for (const vendor of vendors) {
const months: string[] = vendor.months ?? [];
if (months.length < 3) continue;
// Check if the vendor has a roughly monthly cadence
// Sort months and check average gap
const sorted = months.sort();
let totalGap = 0;
for (let i = 1; i < sorted.length; i++) {
const [y1, m1] = sorted[i - 1].split("-").map(Number);
const [y2, m2] = sorted[i].split("-").map(Number);
totalGap += (y2 - y1) * 12 + (m2 - m1);
}
const avgGap = totalGap / (sorted.length - 1);
// Monthly: avg gap ~1-1.5 months, Quarterly: ~2.5-3.5, Yearly: ~10-14
let frequency = "monthly";
if (avgGap > 2 && avgGap <= 4) frequency = "quarterly";
else if (avgGap > 4) frequency = "yearly";
else if (avgGap > 1.5) continue; // irregular, skip
awaicheckMissingReceipts function · typescript · L52-L87 (36 LOC)server/services/missing-receipts.ts
export async function checkMissingReceipts(businessId: string): Promise<number> {
const patterns = await store.getTrackedVendorPatterns(businessId);
let alertsCreated = 0;
const now = new Date();
// Check last month — if we're on the 1st, the previous month just ended
const checkDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const expectedMonth = `${checkDate.getFullYear()}-${String(checkDate.getMonth() + 1).padStart(2, "0")}`;
for (const pattern of patterns) {
if (pattern.frequency !== "monthly") continue;
// Check if there's already an alert for this vendor+month
const hasAlert = await store.hasAlertForVendorMonth(pattern.id, expectedMonth);
if (hasAlert) continue;
// Check if there's a document for this vendor in the expected month
const hasDoc = await store.hasDocumentForVendorInMonth(
businessId,
pattern.vendorName,
expectedMonth,
);
if (!hasDoc) {
await store.createMissingReceiptAlert({
sendMissingReceiptAlerts function · typescript · L92-L176 (85 LOC)server/services/missing-receipts.ts
export async function sendMissingReceiptAlerts(
businessId: string,
alerts: Array<{ id: string; vendorName: string; expectedMonth: string; avgAmountCents: number }>,
): Promise<number> {
if (alerts.length === 0) return 0;
let notified = 0;
const summary = await store.getDashboardSummary(businessId);
// Send email notification if Resend is configured
if (env.RESEND_API_KEY) {
try {
const { Resend } = await import("resend");
const resend = new Resend(env.RESEND_API_KEY);
const alertRows = alerts.map((a) => {
const amount = a.avgAmountCents > 0
? `~₪${(a.avgAmountCents / 100).toLocaleString("he-IL")}`
: "";
return `<tr><td style="padding:6px 12px;border-bottom:1px solid #eee;">${a.vendorName}</td><td style="padding:6px 12px;border-bottom:1px solid #eee;">${a.expectedMonth}</td><td style="padding:6px 12px;border-bottom:1px solid #eee;">${amount}</td></tr>`;
}).join("\n");
const accountant = await store.Source: Repobility analyzer · https://repobility.com
oauthConfig function · typescript · L35-L67 (33 LOC)server/services/oauth.ts
function oauthConfig(provider: OAuthProvider, env: AppEnv) {
if (provider === "gmail") {
return {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
profileUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
scopes: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/gmail.readonly",
],
};
}
return {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
authorizeUrl: `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize`,
tokenUrl: `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`,
profileUrl: "https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName",
scopes: [
"openid",
"profile",
"email",
"offlinbuildOAuthState function · typescript · L74-L84 (11 LOC)server/services/oauth.ts
export function buildOAuthState(businessId: string, provider: OAuthProvider, env: AppEnv): string {
const payload: OAuthStatePayload = {
businessId,
provider,
nonce: randomBytes(12).toString("hex"),
issuedAt: Date.now(),
};
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signature = createHmac("sha256", env.OAUTH_STATE_SECRET).update(encodedPayload).digest("base64url");
return `${encodedPayload}.${signature}`;
}parseOAuthState function · typescript · L86-L105 (20 LOC)server/services/oauth.ts
export function parseOAuthState(state: string, env: AppEnv): OAuthStatePayload | null {
const [encodedPayload, signature] = state.split(".");
if (!encodedPayload || !signature) {
return null;
}
const expected = createHmac("sha256", env.OAUTH_STATE_SECRET).update(encodedPayload).digest("base64url");
if (signature !== expected) {
return null;
}
try {
const payload = JSON.parse(base64UrlDecode(encodedPayload)) as OAuthStatePayload;
const maxAgeMs = 10 * 60 * 1000;
if (Date.now() - payload.issuedAt > maxAgeMs) {
return null;
}
return payload;
} catch {
return null;
}
}buildOAuthStartUrl function · typescript · L107-L130 (24 LOC)server/services/oauth.ts
export function buildOAuthStartUrl(provider: OAuthProvider, businessId: string, env: AppEnv): string {
const config = oauthConfig(provider, env);
if (!config.clientId) {
throw new Error(`${provider} OAuth client id is not configured`);
}
const state = buildOAuthState(businessId, provider, env);
const redirectUri = `${env.API_PUBLIC_BASE_URL}/api/oauth/${provider}/callback`;
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: config.scopes.join(" "),
state,
});
if (provider === "gmail") {
params.set("access_type", "offline");
params.set("prompt", "consent");
params.set("include_granted_scopes", "true");
}
return `${config.authorizeUrl}?${params.toString()}`;
}exchangeOAuthCode function · typescript · L132-L182 (51 LOC)server/services/oauth.ts
export async function exchangeOAuthCode(
provider: OAuthProvider,
code: string,
env: AppEnv,
): Promise<OAuthTokenResult> {
const config = oauthConfig(provider, env);
if (!config.clientId || !config.clientSecret) {
throw new Error(`${provider} OAuth credentials are not configured`);
}
const redirectUri = `${env.API_PUBLIC_BASE_URL}/api/oauth/${provider}/callback`;
const body = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
});
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${provider} token exchange failed: ${text}`);
}
const payload = await response.json() as {
access_token: string;
refresh_token?: string;
fetchOAuthProfile function · typescript · L184-L222 (39 LOC)server/services/oauth.ts
export async function fetchOAuthProfile(
provider: OAuthProvider,
accessToken: string,
env: AppEnv,
): Promise<OAuthProfileResult> {
const config = oauthConfig(provider, env);
const response = await fetch(config.profileUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${provider} profile fetch failed: ${text}`);
}
if (provider === "gmail") {
const payload = await response.json() as { id?: string; email?: string; name?: string };
if (!payload.email) {
throw new Error("Gmail profile missing email");
}
return {
email: payload.email,
externalAccountId: payload.id ?? null,
displayName: payload.name ?? null,
};
}
const payload = await response.json() as { id?: string; displayName?: string; mail?: string; userPrincipalName?: string };
const email = payload.mail ?? payload.userPrincipalName;
if (!email) {
throwrefreshAccessToken function · typescript · L226-L267 (42 LOC)server/services/oauth.ts
export async function refreshAccessToken(
provider: OAuthProvider,
refreshToken: string,
env: AppEnv,
): Promise<OAuthTokenResult> {
const config = oauthConfig(provider, env);
if (!config.clientId || !config.clientSecret) {
throw new Error(`${provider} OAuth credentials are not configured`);
}
const body = new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
});
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${provider} token refresh failed: ${text}`);
}
const payload = await response.json() as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
return {
accessToken: payload.access_token,
refreshToken: payformatDate function · typescript · L22-L29 (8 LOC)server/services/pdf.ts
function formatDate(dateIso: string): string {
try {
const d = new Date(dateIso);
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
} catch {
return dateIso;
}
}Repobility · open methodology · https://repobility.com/research/
getFontPath function · typescript · L40-L57 (18 LOC)server/services/pdf.ts
function getFontPath(): string | null {
// Try multiple paths for different environments (dev vs bundled)
const candidates = [
// Dev: server/services/pdf.ts → server/assets/
path.join(__dirname, "..", "assets", "NotoSansHebrew-Regular.ttf"),
// Bundled ESM: alongside the bundle
path.join(path.dirname(fileURLToPath(import.meta.url)), "assets", "NotoSansHebrew-Regular.ttf"),
// Process cwd fallback
path.join(process.cwd(), "server", "assets", "NotoSansHebrew-Regular.ttf"),
path.join(process.cwd(), "assets", "NotoSansHebrew-Regular.ttf"),
];
for (const candidate of candidates) {
try {
if (existsSync(candidate)) return candidate;
} catch { /* ignore */ }
}
return null;
}generateMonthlyReport function · typescript · L65-L198 (134 LOC)server/services/pdf.ts
export async function generateMonthlyReport(
businessId: string,
monthKey: string,
businessName: string,
accountantName: string,
documents: DocumentRow[],
): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margin: 40,
layout: "portrait",
info: {
Title: `SendToAmram Report - ${monthKey}`,
Author: "SendToAmram",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
// Try to register Hebrew font, fall back to Helvetica
let fontName = "Helvetica";
const fontPath = getFontPath();
if (fontPath) {
try {
doc.registerFont("Hebrew", fontPath);
fontName = "Hebrew";
} catch {
// Font not available, use default
}
}
const paggetBridgeUrl function · typescript · L3-L8 (6 LOC)server/services/whatsapp-bridge-client.ts
function getBridgeUrl(): string {
if (!env.WHATSAPP_BRIDGE_URL) {
throw new Error("WHATSAPP_BRIDGE_URL is not configured");
}
return env.WHATSAPP_BRIDGE_URL.replace(/\/$/, "");
}page 1 / 4next ›