Skip to main content
Glama
Qoxiz20
by Qoxiz20
README.md
# Invoice Generator — LHG Import Export Hub

Phase 2 of the WhatsApp order automation project: structured order → HTML → PDF.

## Current status

- ✅ Phase 1: Product catalog (`catalog.json`) — done. 28 items loaded, only
  **Royal Lion Potato Starch** has full pricing; everything else is flagged
  `needs_review` until the Excel is filled in and re-converted.
- ✅ Phase 2: Invoice generator — done. Products are matched by free-text
  query (e.g. "button mushroom"), not barcode — barcode is an optional
  internal shortcut. Ambiguous or unmatched queries throw a clear error
  with candidates instead of guessing.
- ✅ Phase 3: Google Drive upload — done. PDFs auto-upload to
  `Invoices/{year}/{month}/` in Google Drive, auto-creating subfolders.
- ⬜ Phase 4: WhatsApp webhook — not started.
- ⬜ Phase 5: AI order parsing — not started.

## Setup

```bash
npm install
cp .env.example .env
```

Fill in `.env` with your Supabase project URL and service key, and your
Google Drive service account key path + folder ID.

For Google Drive (OAuth):
1. In Google Cloud Console → APIs & Services → Credentials → Create
   Credentials → OAuth client ID → type "Desktop app".
2. Copy the Client ID and Client Secret into `.env`.
3. Run `node get-refresh-token.js` once — it prints a URL, you approve
   access in your browser, paste back a code, and it prints your
   `GOOGLE_OAUTH_REFRESH_TOKEN`. Copy that into `.env`.
4. Share your Drive "Invoices" root folder with the Google account you
   authorized in step 3 (or just use that account's own Drive directly).
5. Copy that folder's ID from its URL into `GOOGLE_DRIVE_FOLDER_ID`.

You also need to run this SQL once in your Supabase project (SQL editor):

```sql
create table invoice_counters (
  year int not null,
  month int not null,
  last_number int not null default 0,
  primary key (year, month)
);

create or replace function get_next_invoice_number(p_year int, p_month int)
returns int as $$
declare
  next_num int;
begin
  insert into invoice_counters (year, month, last_number)
  values (p_year, p_month, 1)
  on conflict (year, month)
  do update set last_number = invoice_counters.last_number + 1
  returning last_number into next_num;
  return next_num;
end;
$$ language plpgsql;
```

## Usage

```js
const { generateInvoice } = require('./generate-invoice');

const order = {
  customer_name: 'ABC Restaurant',
  customer_address: '123 Jalan Test, Kota Kinabalu, Sabah',
  customer_phone: '+60 12-345 6789',
  payment_method: 'Bank Transfer',
  items: [
    { barcode: '9551029670183', quantity: 2 } // barcode must exist + be priced in catalog.json
  ],
};

generateInvoice(order).then((result) => {
  console.log(result.invoiceNumber, result.outputPath);
});
```

Or run the built-in test:

```bash
npm run generate
```

**Note:** the test order in `generate-invoice.js` currently has an empty items
list, because Royal Lion Potato Starch (the only fully-priced item) is
missing its barcode in the Excel. Running it as-is will fail on purpose
with "Order has no items" — that's the fail-safe behavior working correctly,
not a bug. Fill in a barcode + quantity once at least one product is fully
priced with a barcode, to see a real PDF generated.

## How pricing works

Tier is selected automatically from quantity ordered:

| Quantity | Tier |
|---|---|
| 1–3 | `1-3_unit` |
| 4–10 | `4-10_unit` |
| 11–20 | `11-20_unit` |
| 21–49 | `21-49_unit` |
| 50+ | `50_unit_plus` |

If the matched tier has no price in `catalog.json`, or the barcode doesn't
exist, generation **stops and throws** — it never guesses or generates a
partial invoice.

## Updating the catalog

1. Edit the master Excel.
2. Send it back through Claude to regenerate `catalog.json`.
3. Replace `catalog.json` in this project.

## Folder structure

```
invoice-generator/
├── generate-invoice.js   # main entry point (plain Node, unrelated to Next.js/Vercel)
├── invoice-template.html # FIXED invoice design — edit freely, placeholders must stay
├── catalog.json          # product data — regenerate from Excel, don't hand-edit
├── next.config.js         # minimal Next.js config
├── src/
│   ├── catalog.js        # catalog loading + lookup
│   ├── pricing.js         # tier selection + line/total calculations
│   ├── counter.js         # Supabase-backed running invoice number
│   ├── render.js           # fills HTML template with order data
│   ├── pdf.js              # HTML → PDF via Puppeteer
│   ├── auth.js             # shared Google OAuth client (Drive + Sheets, server-side refresh flow)
│   ├── drive.js             # invoice PDF upload to Drive
│   ├── sheetsTools.js       # MCP tools: read/write/append Google Sheets cells
│   ├── mcpDriveTools.js     # MCP tools: move files in Drive
│   └── whatsappTools.js     # MCP tool: send WhatsApp message (inactive until Meta approval)
├── lib/
│   ├── jwt.js               # sign/verify all tokens this app issues (auth codes, access, refresh, relay state)
│   ├── pkce.js               # PKCE S256 verification
│   └── google-identity.js    # Google login used ONLY for identity check inside /api/authorize
├── app/
│   ├── api/
│   │   ├── mcp/route.js              # the 6 MCP tools, protected by withMcpAuth
│   │   ├── authorize/route.js         # OAuth /authorize — hands off to Google for identity
│   │   ├── oauth/google/callback/route.js  # Google redirects back here; mints our own auth code
│   │   └── token/route.js              # OAuth /token — issues our own access/refresh tokens
│   └── .well-known/
│       ├── oauth-protected-resource/route.js     # RFC 9728
│       └── oauth-authorization-server/route.js    # RFC 8414
├── output/                # generated PDFs land here (gitignored)
└── .env                   # secrets — never committed
```

## MCP server — real OAuth 2.1, not a shared secret

This app is deployed as a Next.js project on Vercel and does two unrelated
jobs from the same codebase:

1. **Resource Server** — exposes 6 tools (`sheets_read_range`,
   `sheets_update_range`, `sheets_append_row`, `sheets_list_tabs`,
   `drive_move_file`, `whatsapp_send_message`) at `/api/mcp`, using
   `mcp-handler` v2 + `@modelcontextprotocol/server` v2 (Streamable HTTP).
2. **Authorization Server** — issues the tokens Claude uses to call those
   tools, at `/api/authorize` and `/api/token`. Google is used *inside*
   this flow only to confirm identity — Google's own access token never
   reaches Claude.

### Why not just use Google directly as the Authorization Server?

Claude requires every access token's `aud` claim to exactly match this
server's canonical URL (RFC 8707 — Claude enforces this as a MUST). Google's
OAuth server doesn't support resource-scoped tokens — it always audiences
tokens to its own Client ID, never to an arbitrary URL. Passing a raw
Google token to Claude would fail Claude's audience check outright. So this
app mints its own resource-bound tokens instead, using Google only as a
one-time login check inside `/api/authorize`.

### The flow

```
Claude → GET /api/mcp (no token)
       ← 401 + WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource"

Claude → GET /.well-known/oauth-protected-resource
       ← { resource: ".../api/mcp", authorization_servers: [".../"] }

Claude → GET /.well-known/oauth-authorization-server  (on OUR domain, not Google's)
       ← { authorization_endpoint: ".../api/authorize", token_endpoint: ".../api/token", ... }

Claude → browser opens GET /api/authorize?client_id=...&code_challenge=...&resource=...
       → we redirect to Google's real login screen (identity check only)
       ← Google redirects back to /api/oauth/google/callback with a Google code
       → we exchange it, read the email, check it against ALLOWED_GOOGLE_EMAIL
       → we mint OUR OWN short-lived authorization code, redirect back to Claude

Claude → POST /api/token  (grant_type=authorization_code, our code + PKCE verifier)
       ← { access_token: <our JWT, aud=.../api/mcp>, refresh_token: <our JWT> }

Claude → POST /api/mcp  Authorization: Bearer <our access token>
       → withMcpAuth verifies signature + issuer + audience + scope
       → tool runs exactly as before
```

Two Google OAuth interactions exist in this codebase and never touch each
other: `src/auth.js` (server's own long-lived Drive/Sheets access, used by
both the invoice generator and the MCP tools) and
`lib/google-identity.js` (one-time human login check inside `/api/authorize`
only). Same Google Cloud OAuth Client, two separate purposes.

### Environment variables

All secrets live in Vercel's environment variables — nothing is hardcoded
in source. See `.env.example` for the full annotated list. Summary:

| Variable | Purpose |
|---|---|
| `MCP_ISSUER_URL` | Your app's stable production URL — used to construct every issuer/resource/redirect value consistently |
| `MCP_JWT_SECRET` | Signs every token this app issues. Rotating it revokes everything at once (this design has no per-token revocation) |
| `MCP_CLIENT_ID` / `MCP_CLIENT_SECRET` | Values *you* make up, entered into Claude's "Use your own OAuth client" field |
| `ALLOWED_GOOGLE_EMAIL` | The one Google account allowed to complete login |
| `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` / `_REFRESH_TOKEN` | Reused from the existing Drive/Sheets setup — needs one new redirect URI added (see below) |
| `SUPABASE_URL` / `_SERVICE_KEY`, `WHATSAPP_TOKEN` / `_PHONE_NUMBER_ID` | Unchanged from before |

### Google Cloud Console setup

Add **both** of these to your existing OAuth Client's Authorized redirect URIs
(alongside whatever `get-refresh-token.js` already uses):
```
{MCP_ISSUER_URL}/api/oauth/google/callback
http://localhost:3000/api/oauth/google/callback   (for local testing)
```

### Claude custom connector setup

In Claude's connector dialog → Advanced settings → **"Use your own OAuth client"**:
- Server URL: `{MCP_ISSUER_URL}/api/mcp`
- Client ID: your `MCP_CLIENT_ID` value
- Client Secret: your `MCP_CLIENT_SECRET` value

Claude's own redirect URIs (already whitelisted on Anthropic's side, nothing
for you to configure): `https://claude.ai/api/mcp/auth_callback` and
`https://claude.com/api/mcp/auth_callback`.

### Deploy

```bash
npm install
npm install -g vercel
vercel          # first deploy, get your URL, set MCP_ISSUER_URL to it
vercel --prod   # redeploy after setting all env vars in the dashboard
```

### Local test procedure

Run `npm run dev` (starts on `http://localhost:3000`) with `.env` filled in
and `MCP_ISSUER_URL=http://localhost:3000`.

**1. OAuth discovery**
```bash
curl http://localhost:3000/.well-known/oauth-protected-resource
curl http://localhost:3000/.well-known/oauth-authorization-server
```
Both should return JSON (not 404). Confirm `resource` and `issuer` show
`http://localhost:3000`.

**2. Authorization (needs a real browser — this step logs you into Google)**
Open in a browser:
```
http://localhost:3000/api/authorize?response_type=code&client_id=YOUR_MCP_CLIENT_ID&redirect_uri=http://localhost:3000/callback-test&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&resource=http://localhost:3000/api/mcp
```
(The `code_challenge` above corresponds to verifier `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk` — a fixed PKCE test pair, fine for local testing.)
You should be sent to Google's real login screen, then redirected back to
`http://localhost:3000/callback-test?code=...` (that URL will 404 since
there's no real page there — that's fine, just copy the `code` value from
the address bar).

**3. Token issuance**
```bash
curl -X POST http://localhost:3000/api/token \
  -d "grant_type=authorization_code" \
  -d "code=PASTE_CODE_FROM_STEP_2" \
  -d "redirect_uri=http://localhost:3000/callback-test" \
  -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  -d "client_id=YOUR_MCP_CLIENT_ID" \
  -d "client_secret=YOUR_MCP_CLIENT_SECRET"
```
Should return `access_token`, `refresh_token`, `expires_in: 3600`.

**4. MCP authentication**
```bash
# Without a token - should get 401 with WWW-Authenticate header
curl -i -X POST http://localhost:3000/api/mcp -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}'

# With the access token from step 3 - should succeed
curl -X POST http://localhost:3000/api/mcp \
  -H "Authorization: Bearer PASTE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}'
```

**5. Calling each tool** — list them first, then call one as a smoke test:
```bash
curl -X POST http://localhost:3000/api/mcp \
  -H "Authorization: Bearer PASTE_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{}}'

curl -X POST http://localhost:3000/api/mcp \
  -H "Authorization: Bearer PASTE_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"sheets_list_tabs","arguments":{"spreadsheetId":"YOUR_TEST_SHEET_ID"}}}'
```
Repeat for `sheets_read_range`, `sheets_update_range`, `sheets_append_row`,
`drive_move_file`, and `whatsapp_send_message` (the last will return its
"not yet configured" message until `WHATSAPP_TOKEN` is set — that's expected).