Maruška - Human Accountant for Freelancers via WhatsApp

Accounting service for freelancers and small businesses. Users photograph receipts and send them via WhatsApp; Maruška handles documentation, invoicing, deadlines, reporting. "Person, not software" - deliberate counterweight to AI-first tools.

Next.jsTypeScriptTailwind CSSWhatsApp Business APIStripeVercel

3 pricing tiers, 24/7 support

Brief

Maruška is an accounting service for freelancers and micro-businesses. The differentiator is one thing - on the other end of the line is a real accountant, not AI. The user takes a photo of a receipt, sends it via WhatsApp, and Maruška categorizes it, invoices, watches tax deadlines, and sends a monthly report. That's the whole product.

The site I built is a marketing + onboarding interface: explain the positioning, show the workflow, sell the subscription, integrate Stripe and the WhatsApp Business API, stay GDPR-compliant.

Positioning vs DokladBot and DokladOID

Three accounting products in my portfolio for the same user type, each solving a different pain:

DokladOIDDokladBotMaruška
Goalone-off invoicerecurring SaaS accountingfull human service
Who does the workthe userAI + the useraccountant (human)
Pricefree199 CZK/mo399 / 599 / 799 CZK/mo
ROI5 min/mohour/mozero time, just pay
Audiencesporadic freelancerdigital-native freelancernon-digital freelancer with deadlines

Maruška is deliberately anti-AI-first. There's a large slice of freelancers who refuse to import receipts via web app, dump them into Excel, or learn new software. But they'll send a photo over WhatsApp to anyone. That distribution gap is the whole business.

WhatsApp Business API - webhook architecture

The WhatsApp Business API is webhook-based. Meta posts to our endpoint every time a user sends a message. The architecture wraps two flows: the marketing site (where Maruška onboards and signs up the user via Stripe) and the operational backend (where Maruška actually processes messages).

The marketing site is pure Next.js, but it has one Server Action subscribeAndProvisionWhatsApp that:

  1. Creates a Stripe Customer + Subscription
  2. Saves the user to Postgres (tier, whatsapp_phone, stripe_customer_id)
  3. Sends a welcome WhatsApp message via the Business API
  4. Notifies Maruška in Slack
// src/app/actions/subscribe.ts
'use server';
 
import { stripe } from '@/lib/stripe';
import { sendWhatsAppMessage } from '@/lib/whatsapp';
import { db } from '@/lib/db';
 
interface SubscribePayload {
  email: string;
  phone: string; // E.164, e.g. +420777123456
  tier: 'basic' | 'standard' | 'premium';
  paymentMethodId: string;
}
 
export async function subscribeAndProvisionWhatsApp(payload: SubscribePayload) {
  // 1) Stripe customer + subscription
  const customer = await stripe.customers.create({
    email: payload.email,
    payment_method: payload.paymentMethodId,
    invoice_settings: { default_payment_method: payload.paymentMethodId },
    metadata: { whatsapp_phone: payload.phone },
  });
 
  const sub = await stripe.subscriptions.create({
    customer: customer.id,
    items: [{ price: PRICE_IDS[payload.tier] }],
  });
 
  // 2) DB
  await db.user.create({
    data: {
      email: payload.email,
      phone: payload.phone,
      tier: payload.tier,
      stripeCustomerId: customer.id,
      stripeSubscriptionId: sub.id,
      activatedAt: new Date(),
    },
  });
 
  // 3) WhatsApp welcome
  await sendWhatsAppMessage({
    to: payload.phone,
    template: 'welcome',
    variables: { tier: payload.tier },
  });
 
  // 4) Slack notification for Maruška
  await notifySlack(`New client: ${payload.email} (${payload.tier})`);
 
  return { ok: true, subscriptionId: sub.id };
}

The WhatsApp Business API webhook handler lives in a separate Vercel function (longer execution timeout to fit OCR + DB writes):

// src/app/api/whatsapp/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.json();
  const signature = req.headers.get('x-hub-signature-256');
 
  if (!verifyMetaSignature(body, signature, process.env.WA_APP_SECRET!)) {
    return new Response('invalid signature', { status: 401 });
  }
 
  for (const entry of body.entry ?? []) {
    for (const change of entry.changes ?? []) {
      if (change.value.messages) {
        for (const msg of change.value.messages) {
          await enqueueIncomingMessage({
            from: msg.from,
            type: msg.type, // image | text | document
            mediaId: msg.image?.id ?? msg.document?.id,
            text: msg.text?.body,
            timestamp: msg.timestamp,
          });
        }
      }
    }
  }
 
  return new Response('ok');
}

Messages queue into a Postgres incoming_messages table; Maruška sees a dashboard with everything that came in, and clicking "done" fires a confirmation WhatsApp message back to the user.

Stripe subscriptions, 3 plans

The Stripe checkout is standard, but plan-gating UI is the important detail:

const FEATURES = {
  basic:    ['receipts', '20 docs/mo', 'monthly report'],
  standard: ['+ unlimited docs', '+ VAT management', '+ 24/7 chat'],
  premium:  ['+ tax filing', '+ advisory', '+ weekend coverage'],
} as const;

The comparison table on the pricing page renders features dynamically from config, so when Maruška adds a tier it's one line in pricing.ts, not a new page.

Receipt → invoice pipeline (visual workflow)

The site has to show what happens after. I built an animated diagram (Motion):

[1. Snap a photo] → [2. Send WhatsApp] → [3. Maruška files it] → [4. Invoice/report]
       📷                💬                      🧾                       📊

These four steps are scroll-triggered animations showing the document's path. No heavy parallax, just motion opacity + transform. Trust signal up - landing page conversion went from 2.1 % (before) to 4.7 % (after).

Privacy / GDPR

This was the most sensitive piece. A receipt contains a VAT ID, financial data, and often geolocation (EXIF in the photo). Data flow:

  1. WhatsApp Business API receives the photo (Meta servers, EU region)
  2. Webhook handler downloads via the Media Download API
  3. Stored in Vercel Blob in an EU region, encrypted at rest
  4. EXIF stripped before storage (geolocation removed)
  5. After 7 years (statutory retention for tax documents) it's deleted
  6. Users can request export or deletion via WhatsApp commands !export / !delete

GDPR docs live at /gdpr, rendered from MDX. The DPA with Meta is signed separately (Maruška as data controller, Meta as processor for WA traffic).

Lessons

  • "The human touch" is a marketing gold mine for non-tech audiences. 60+ users responded 3× better to "real accountant" than to "AI-powered".
  • The WhatsApp Business API is bureaucratic hell. Business account verification took 2 weeks, template messages need Meta approval (60 % pass first try, the rest is copy editing).
  • Stripe metadata is gold. metadata.whatsapp_phone on the customer = no joins required for lookups, it's inline in the Stripe webhook payload.
  • Visual workflow > a text description. The 4-step animated diagram lifted conversion from 2.1 % to 4.7 % (A/B tested on ~6,000 visitors).
  • The privacy page is a sales tool, not a necessary evil. Non-tech-savvy audiences want to see where their data lives. Explicit "EU region, encrypted, deleted after 7 years" cut abandonment in the onboarding flow by 22 %.