Files
accounted/docs/WHITELABEL.md
T
Mattsson d54b43f80f Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:56:17 +02:00

9.6 KiB

Whitelabel fork checklist

Accounted is whitelabel-friendly: every user-visible brand reference reads from a single BrandingService (lib/branding/service.ts). If you don't override anything, the app behaves exactly like upstream gnubok. To run your own brand on top of Accounted, fork the repo and override the values you care about.

Quick start

# 1. Fork erp-mafia/gnubok on GitHub → you/your-brand
# 2. Clone and add upstream remote (one-time)
git clone https://github.com/you/your-brand
cd your-brand
git remote add upstream https://github.com/erp-mafia/gnubok

# 3. Copy the example branding extension
cp -r extensions/general/_example-branding extensions/general/your-brand
# Edit extensions/general/your-brand/index.ts with your brand values

# 4. (Optional) Set env vars instead of / in addition to the extension. See "Env vars" below.

# 5. Enable the extension
# Edit extensions.config.json and add "your-brand" to the array.

# 6. Run locally
npm run setup:extensions
npm run dev

# 7. Deploy to your hosting (Vercel, Docker, etc.)

Env vars

All branding can be set via env vars. Public ones use NEXT_PUBLIC_BRANDING_* (build-time inlined, available in client components). Server-only ones use BRANDING_*.

Env var Field Default
NEXT_PUBLIC_BRANDING_APP_NAME appName Accounted
NEXT_PUBLIC_BRANDING_APP_DESCRIPTION appDescription Ekonomihantering
BRANDING_LEGAL_ENTITY legalEntity Arcim
BRANDING_SUPPORT_EMAIL supportEmail support@gnubok.se
BRANDING_PRIVACY_EMAIL privacyEmail privacy@gnubok.se
BRANDING_SECURITY_EMAIL securityEmail security@arcim.io
NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM authEmailFrom: From address Supabase Auth sends verification / reset emails from. Used to pre-populate the from: query on the "open in Gmail" button after signup. Set to whatever you configured in your Supabase Auth SMTP. noreply@gnubok.se
NEXT_PUBLIC_APP_URL appUrl https://app.gnubok.se
NEXT_PUBLIC_BRANDING_LOGO_PATH logoPath /gnubokiceon-removebg-preview.png
NEXT_PUBLIC_BRANDING_FAVICON_PATH faviconPath /favicon.ico
NEXT_PUBLIC_BRANDING_APPLE_ICON_PATH appleTouchIconPath /icons/icon-192.png
NEXT_PUBLIC_BRANDING_PWA_ICON_BASE pwaIconBasePath /icons
NEXT_PUBLIC_BRANDING_THEME_COLOR themeColor #304D83
NEXT_PUBLIC_BRANDING_MANIFEST_THEME_COLOR manifestThemeColor #1a1a1a
NEXT_PUBLIC_BRANDING_MANIFEST_BG_COLOR manifestBackgroundColor #ffffff
NEXT_PUBLIC_BRANDING_HIDDEN_NAV hiddenNavHrefs (comma-separated, e.g. /salary,/customers) `` (none hidden)

Resolution order (last wins): defaults → env vars → extension override.

NEXT_PUBLIC_* env vars are inlined at build time. Changing them requires a fresh npm run build to propagate.

NEXT_PUBLIC_BRANDING_APP_NAME also stamps the service worker push-notification fallback title in public/sw.js. This happens at build time for Vercel/local builds (via scripts/inject-public-branding.mjs, run from prebuild) and at container start for Docker (via docker-entrypoint.sh).

Email / Resend (when email or invoice-inbox extensions are enabled)

Env var Purpose
RESEND_API_KEY Resend API key: required for both outbound mail and the inbox webhook
RESEND_FROM_EMAIL Default From address (e.g. noreply@your-brand.se); also used as the address you From-spoof through Resend
RESEND_INBOUND_DOMAIN Domain used to compose per-company invoice-inbox addresses: {local-part}@{RESEND_INBOUND_DOMAIN}
RESEND_INBOUND_WEBHOOK_SECRET Verifies the /inbound webhook signature from Resend
RESEND_DELIVERY_WEBHOOK_SECRET Verifies the /delivery-status webhook signature from Resend. Optional: without it, invoice delivery history shows "sent" but never the delivery outcome

Things you MUST NOT change

These are stable contracts. Renaming them breaks existing data, sessions, or external clients (npm package consumers, MCP connectors, browser sessions, invite links). Leave them alone in your fork:

Identifier Where Why
gnubok-company-id cookie Active company context: renaming breaks logged-in sessions
gnubok-invite-token cookie Pre-auth invite token holding: renaming drops in-flight invites
gnubok_sk_ API key prefix All issued API keys; existing clients fail validation
gnubok_inv_ invite token prefix All sent invite links break
gnubok_* MCP tool names (gnubok_list_invoices, etc.) Published MCP API: Claude clients have these cached
gnubok-mcp npm package name Whitelabel users still install npx gnubok-mcp. Document GNUBOK_URL=https://app.your-brand.se/api/extensions/ext/mcp-server/mcp so they hit your endpoint
GNUBOK_API_KEY env var read by gnubok-mcp package Same reason: npm consumer expects this name

What's outside this branding service

A few things that look brand-related but are configured elsewhere:

  • Supabase auth emails (password reset, magic link): set in the Supabase dashboard for your project, not in code.
  • Resend sending domain: verify noreply@your-brand.se (or wherever) in Resend, set RESEND_FROM_EMAIL.
  • DNS / domain: point app.your-brand.se at your Vercel deployment.
  • OAuth redirect allowlist for MCP: app/api/mcp-oauth/authorize/route.ts lists claude.ai/api/*, claude.com/api/*, and localhost. Your domain is the OAuth issuer, not a redirect target: no change needed unless you're integrating with new MCP clients.
  • iCal feed PRODID (lib/calendar/ics-generator.ts): defaults to erp-base.se, callers may pass their domain.
  • NEXT_PUBLIC_APP_URL: used as the OAuth issuer. Set this to your domain (e.g. https://app.your-brand.se).
  • Skatteverket submission identity: extensions/general/skatteverket/lib/api-client.ts does not set a custom User-Agent; submissions go out with the Node/Vercel runtime default. If your deployment needs to identify itself to Skatteverket under a different brand, that's a future enhancement (env var + header), not something the current branding service covers.

Staying in sync with upstream

Add this workflow at .github/workflows/sync-upstream.yml to your fork. It runs weekly and opens a PR with upstream changes:

name: Sync from upstream

on:
  schedule:
    - cron: '0 3 * * 1'  # Mondays 03:00 UTC
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Add upstream and fetch
        run: |
          git remote add upstream https://github.com/erp-mafia/gnubok
          git fetch upstream main

      - name: Create sync branch and merge
        id: merge
        run: |
          BRANCH="sync/upstream-$(date +%Y-%m-%d)"
          git checkout -b "$BRANCH"
          if git merge --no-edit upstream/main; then
            echo "status=clean" >> "$GITHUB_OUTPUT"
          else
            echo "status=conflict" >> "$GITHUB_OUTPUT"
            git merge --abort || true
          fi
          echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"

      - name: Push and open PR (clean merge)
        if: steps.merge.outputs.status == 'clean'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if git diff --quiet origin/main..HEAD; then
            echo "Up to date with upstream: nothing to do."
            exit 0
          fi
          git push origin "${{ steps.merge.outputs.branch }}"
          gh pr create \
            --base main \
            --head "${{ steps.merge.outputs.branch }}" \
            --title "Sync from upstream Accounted" \
            --body "Automated weekly sync from \`erp-mafia/gnubok@main\`."

      - name: Report conflict
        if: steps.merge.outputs.status == 'conflict'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh issue create \
            --title "Upstream sync conflict ($(date +%Y-%m-%d))" \
            --label sync-conflict \
            --body "Automated upstream merge hit a conflict. Resolve manually: \`git fetch upstream && git merge upstream/main\`."

Conflict avoidance

The fork-friendliness of this design depends on you keeping changes confined to your branding extension folder. Every file you edit in lib/, app/, or components/ becomes a potential conflict on the next upstream merge. If you find yourself wanting to override something the branding service doesn't expose, prefer:

  1. Open an upstream issue: the branding service is intentionally minimal; missing fields can be added.
  2. PR a hook upstream: extending the service or adding a registry pattern keeps your fork clean.

Verifying your whitelabel

After deploying:

  • Visit /: browser tab title shows your brand.
  • Visit /login and /register: your logo renders.
  • View source of /manifest.webmanifest: name, short_name, theme_color reflect your overrides.
  • Trigger an invite email: From line says <your-brand> <noreply@...>, body uses your name.
  • Visit /dpa and /privacy: legal entity and contact email are yours.
  • Open OAuth flow (/api/mcp-oauth/authorize?...) from a test MCP client: consent page references your brand.
  • Submit support form (Settings → Support): internal subject prefix is [<your-brand> support].