Compare commits
1
Commits
main
..
925ed114a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925ed114a6 |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx next:*)",
|
||||
"Read(//mnt/m2cache/appdata/smarthome/**)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(curl -s http://localhost:4100/ -I)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(docker port:*)",
|
||||
"Bash(docker inspect:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(k, v['IPAddress']\\) for k,v in d.items\\(\\)]\")",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(node_modules/.bin/vitest:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(docker exec postgres-personal:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
#ENC[AES256_GCM,data:8Y6694wDaHKDf03nhurNnRYhbMv4TXBNI7HJnh0I4jM0tq1W4iq0NLgQ4W0wDx/cVat0szyrq/tAYsUj+LuRMMdHEY3Fcdp51MT2KtGXEa9kw/Wp,iv:jJTLMVvpbW7cKOGWV6x2JuAqkx6QgOKlw4K9NXA9fa4=,tag:9DhD7Zm9BtJPPH+mXjzZuQ==,type:comment]
|
||||
#ENC[AES256_GCM,data:HMMVGfE8jPTsDyz9y0d+vfyRaf0B3segW04TFPIZDtJBlDD/RUMbv3KylOm0e+iBkJQFAIeAgBrvxr9+myD7XwYWJtIjVYrcgktHlcC8GoH4cotGDQni58uhMknlF33TdOVxTxs=,iv:4FH4Qbe4LMdUiIRLhhr+0Dnp7tT85EwQ0mcHqfz3T+Y=,tag:DT4muDzq/cKeGhgriX+Rbw==,type:comment]
|
||||
#ENC[AES256_GCM,data:L39MkIKSEcj9C/aXvlz9zPDvR0Awgp4OYVXYX0ctIuwCKq0qmbApFv+fj6YbcnS7vlWttlDcyypnF8KbJe+0bDFRLN+MBzmWjMUhjKPz8eYe+v6gp9Mz,iv:1H88VnLGt6P81KP3e3lonvpL/OhtBCardjhPT0hCOag=,tag:jJmHguBHWp0Kl3Je0bEG3w==,type:comment]
|
||||
#ENC[AES256_GCM,data:BBpTe55+cpatCSGS4g7YaWb0PRS4QjbrUE+mG2CzVmtS63s5onN22swVYtQw/NaMcv8qkrR2imww/vHnH/Z98IuJpAZIy8CXTMGNzXvFU82LNFIcb2QPZ1bWHHWLJfj7EHoDRgCBvuDkO8cAORgmI1U3m1ekZe82,iv:fI7a+edL5LpLVt+2vD75YwcxzHaO/Hz6OfPylSCTYEs=,tag:leDqySg/m9G8vlddrhwWhg==,type:comment]
|
||||
#ENC[AES256_GCM,data:hKjWUHaA7rYY/VjDprmcQ++mphIBzPyFJVylFGyDEfRu4pSoFN3ZGD3B0H8P2wiN8IarrJz4p6hQzfrB248lEojxKBoUmOU1Dxegv1qhiNO0liIQnZWTtKrenz4zzOdb3sw=,iv:CfydB3duDyuuSphNz5OuZQMbWpJBW0u1Os94KeclgXw=,tag:PAn6S9DCeizHcaxCvS+jdg==,type:comment]
|
||||
#ENC[AES256_GCM,data:hVUROHUoWJuBofyc4qdzTC1+PrecP8jG/1i/xddsTxthNwhYBiEcvq2aJhfXggbNSC1+PJ8AGg2sCrHL3NfD7KBAX33GgdDx0JAeHsyfsHN9hc583cjeVQDlOmocHGrBkGFTOi1g,iv:uiVFAPKXCQhTUFUSj0yPaX3l+eDmhsrwRrPQkTaksig=,tag:pyW76cx0dPErUWXoxW95+A==,type:comment]
|
||||
#ENC[AES256_GCM,data:BOynLXwT+rKrTd1dxnVjveABiJaA94Zl2UQ/yPg5It+cbUNUpJ/GMlrR5OLJQMkw5OD+EkeEMqieY0KYTAualEKOoaIG6T+c0zZAjYNrmWBZYrPuOtpLk4Bzwuel0agrFAs3xqvmsk/OuktQNyvmXMgUpwnzD0U=,iv:WZn4CCL0tzzdorI0zl/mqE+/MMkNtkIyo1SBHpboFw4=,tag:kY/+mg9qyLAxC27UwnXacA==,type:comment]
|
||||
#ENC[AES256_GCM,data:KzC0cK7daY++mskeHmBEjlmDvmC2oncQ18mxwB1tGS0hp2X7lOOYgMYnRwGZRy5Gd0ymfJF8M52GDIV7VGKsOFT2Rt+T3EcCJ8ng1Q1EJpTPk1139g27,iv:ZTg+sbmSuX3oJrf6AWkWFwV2eS+D7j0RNP57d6TGh6A=,tag:bTtnmShFZPxNi9hzI3PVrQ==,type:comment]
|
||||
DATABASE_URL=ENC[AES256_GCM,data:fnfh3OD0nX7xmdCy74/8uoDxu7JNsplPbJeZ/YUrwzrr5BrdJx5+CUbIc/BDx1I1+sId0tu5G2rx1GiPJUKSVw9fKMLrP2yXk8EUjAKcTl1ozOEAxVsW6S/0fqjdBheK2iLgomcQ6LLhT2CHBUadmPNoOEpuYGRY16akPotafWGBGxZiVmg9zD0DZwgxY0ASJ6NQKn5Fup1+FAkSuLPV5Ok1evDecVol9La0bp3pVT6kX0bWQlP8SrxOpznZ2XQbHjXU6xNXJvaA2mVeP6qjCDxJbXX8BB+I52l1ZR5mQ7RZSpossbwT+q6yXGmxUVKDP54oqq7eMasNQ4AkpWrgTSSivogBATuGQyVvBUEeymRWfPRayliGMoEbiITO3Lf6/Z+u+xcAWDUoThJZ3LemScC/RgRN/PG3Dnx1b/uF0piFRDF59xNela515QlQqUDiwoxAdO7hhY1l/gH/VwZEvp4GYJgMqTIP+85Ta2y0Ph4z9clSSHOc/dkAnknRyQDCpbtDSR+DCppzQ9OgUzPK7Popqa1k/yO6Oo+1OTpov3I18Yyh+Vy00kYx9rR2AAp5C32K2rYkdYrEiK3erIBhKNPrLrYRiT/2LReArMAxpUk1iM9uDPnx/JEhISJRlD+PWcF1R8i8CiBc+OhTFOlhKykEUdpXBv4eBzqB0tSwsEnT/zJzMLvqiVoYX0fnGlS61Lg06ZIRohzQLZAU6A+m0rOOFmRkHS6oZbkOnRxf8c5QfjVUq5WxmPNEFWHsIi2LsbjkDLk9yoDLIXflO+G2sP6JB/CWvCXAK++5MiS0G6ijNBEi+kjjHokE/Snpaq+Uw7JynAFKJ0QVMT7YP9YGfVJu2cYIvEkpWtz8,iv:D2HI2gwY9R7jXFsClkV7qKDDxvBips8BVAGOPKLNZCk=,tag:cY8AgAkGXJQZpbUsQBiRxQ==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkSGtocXNOZXU0NUZSZlBY\nYmpMbnJVbVVSV2xnWVdwUVBObWtIb05raW1jCkdJRzUyTlBGSjFISks3OHN0clJY\ncmluR0o0aGlXU3VOaVVUc0t5RWk4NVUKLS0tIHdZUVZrd3JxUmpnbWJkUzZBaHRx\nZ2lKK0NZT0RjU1J0andQMVRPMGdoL2sKlgWx9xKOabP5q4cmHPVVD7xuwn6/OV6V\nZR6MXV07XzLfUl3G5NMeOBI4e6s9y+xZGSoDWeWB194euASTlyiwwA==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||
sops_lastmodified=2026-05-15T09:13:11Z
|
||||
sops_mac=ENC[AES256_GCM,data:BhA6Tbo2bNOYzuoYBd/3nYp9CrZx7Nspl6Us1hpUIXR/EEOLRUCofqbVyDzU+2CNQMNivPYyltQ4nRdapDQwKWlZjTEufSkRKNwafr8eexN3Dwo1j3Njw9bK26ytG/d1Dm3jXOKogRRUZYsQqtHZBmZwuF4JvL49x+ODQ0Hco1o=,iv:6cIK8//MbBoiPSsxJnp7oKiz04B1hxa4vK0QFEBzHP4=,tag:TT5b8KSO+UUZJ/hOJqkMpw==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.9.4
|
||||
@@ -1,39 +0,0 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install
|
||||
run: npm ci
|
||||
# The Prisma client is generated into src/generated/prisma, which is
|
||||
# gitignored — so a fresh checkout has no client and every test that
|
||||
# reaches src/lib/db.ts dies on "Cannot find package
|
||||
# '@/generated/prisma/client'". Schema-only, so it needs no database.
|
||||
# Without this the pipeline had been red on every run since at least
|
||||
# ae0c34f, which is how the failure stayed invisible: it looked like
|
||||
# the normal colour.
|
||||
- name: Generate Prisma client
|
||||
run: npx prisma generate
|
||||
# Blocking since #79. It was advisory from 2026-07-19 while 23 errors sat
|
||||
# across the pages and the integration tests; those are now fixed, so a
|
||||
# new one must not be able to slip in behind a green tick. Warnings are
|
||||
# still allowed (eslint exits 0 on them) — there are 9 left, all
|
||||
# `no-unused-vars` or `exhaustive-deps` advisories, none of which should
|
||||
# stop a push.
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
# Typecheck is separate from `next build`: nothing else in CI compiles the
|
||||
# test files, so a type error in a fixture could only ever be found on a
|
||||
# developer's machine.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
@@ -32,7 +32,6 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.sops
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -42,10 +41,3 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# Raw statement exports — real financial data, never commit
|
||||
dump/
|
||||
|
||||
# Python tooling for scripts/ (split_csv_match.py)
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
creation_rules:
|
||||
- path_regex: \.env$
|
||||
age: age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||
@@ -1,37 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
Application code lives in `src/`. Next.js App Router pages and API route handlers belong in `src/app/`; reusable UI components are in `src/components/`; database access, query functions, hooks, authentication, and domain helpers are in `src/lib/`. Tests are separated into `src/__tests__/unit/` and `src/__tests__/integration/`. PostgreSQL schema and numbered SQL migrations live under `prisma/`, static assets under `public/`, operational scripts under `scripts/`, and design notes under `docs/`.
|
||||
|
||||
Keep data flow consistent: API routes call query functions in `src/lib/queries.ts`, which use `queryRaw()` from `src/lib/db.ts`; client components access APIs through TanStack Query hooks in `src/lib/hooks.ts`.
|
||||
|
||||
**Task tracking is the Vikunja board at `https://tasks.bosecamp.com`** (project *Work*, saved filter **finance-app**), which replaced the smarthome repo's `ACTIONS.md` on 2026-07-30. Update the ticket in the same change as the code. See `CLAUDE.md` → "Work tracking".
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `npm ci` installs the locked dependency set (Node 22 is used in CI).
|
||||
- `npm run dev` starts the local Next.js development server.
|
||||
- `npm run build` creates a production build; `npm start` serves it.
|
||||
- `npm run lint` runs the Next.js ESLint configuration. **Blocking in CI since #79** — the pre-existing debt is cleared, so it must stay at zero errors (warnings are still allowed).
|
||||
- `npx tsc --noEmit` typechecks, including the test files. Also blocking in CI.
|
||||
- `npm test` runs fast unit tests.
|
||||
- `npm run test:setup` creates/refreshes the `personal_test` database (run it once, and after any migration).
|
||||
- `npm run test:integration` runs database-backed tests. It re-resolves the DSN from the live container first (`scripts/resolve-test-db.sh`), so `.env.test` must never carry a hand-written host — see `CLAUDE.md` → Testing.
|
||||
- `npm run test:all` runs both test suites.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use strict TypeScript, two-space indentation, semicolons, and double quotes, matching existing files. Name React components and types in PascalCase, functions and variables in camelCase, and files/routes in kebab-case. Use the `@/` alias for imports from `src/`. Preserve owner scoping and prefer transaction overrides with `COALESCE` in financial queries. Every API route must authenticate before accessing data.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Vitest is the test framework. Name tests `*.test.ts` and place pure logic tests under `unit/`; put PostgreSQL-dependent behavior under `integration/`. Add regression coverage for query, rule, reconciliation, and category changes. No numeric coverage threshold is configured; focus on meaningful edge cases and run `npm run test:all` before submitting database-related changes.
|
||||
|
||||
## Database, Security & Configuration
|
||||
|
||||
Add schema changes as the next numbered `prisma/migrations/NNNN_description/migration.sql`. Never commit `.env`, `.env.test`, raw statements in `dump/`, or other financial data. Consult `CLAUDE.md` and relevant `docs/` notes before changing splits, settlements, loans, reconciliation, or statement accounting.
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure lint, typecheck, unit tests and the production build pass; call out any migration steps.
|
||||
@@ -1,423 +1,36 @@
|
||||
# Finance App
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
Personal finance tracker built on Next.js 16 (App Router), PostgreSQL, and Prisma. Bank statements are ingested automatically from Paperless-NGX via an N8N workflow that uses Gemini to extract structured data from PDF statements.
|
||||
## Getting Started
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
|
||||
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
|
||||
- **Database**: PostgreSQL (`postgres-personal` container)
|
||||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
|
||||
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `statements`
|
||||
The top-level document, one row per billing period per account.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `bank_name` | text | Normalised bank name (e.g. "American Express") |
|
||||
| `card_name` | text | Product name (e.g. "Rewards Travel Adventures") |
|
||||
| `account_number` | text | Account/card number (spaces stripped) |
|
||||
| `account_type` | text | Raw account type string from statement |
|
||||
| `statement_type` | text | Normalised type: `Credit Card`, `Business Card`, `multi-currency account`, etc. |
|
||||
| `account_holder_name` | text | Name on the account if extracted |
|
||||
| `billing_start_date` | date | Period start |
|
||||
| `billing_end_date` | date | Period end — used as the deduplication anchor |
|
||||
| `opening_balance` | numeric | Balance at start of period |
|
||||
| `closing_balance` | numeric | Balance at end of period |
|
||||
| `total_credits` | numeric | Sum of all credits in period |
|
||||
| `total_debits` | numeric | Sum of all debits in period |
|
||||
| `total_amount_due` | numeric | Amount due (credit cards) |
|
||||
| `minimum_amount_due` | numeric | Minimum payment due (credit cards) |
|
||||
| `payment_due_date` | date | Payment due date (credit cards) |
|
||||
| `credit_limit` | numeric | Credit limit (credit cards) |
|
||||
| `available_credit` | numeric | Available credit at statement date |
|
||||
| `interest_charged` | numeric | Interest charged this period (from statement summary) |
|
||||
| `fees_charged` | numeric | Fees charged this period (from statement summary) |
|
||||
| `currency` | text | Statement currency (e.g. `AUD`, `USD`) |
|
||||
| `exchange_rate_to_aud` | numeric | FX rate at ingestion time (live from open.er-api.com) |
|
||||
| `owner_id` | int FK → `participants` | Which person owns this statement |
|
||||
| `paperless_doc_id` | int | Paperless-NGX document ID — deduplication key |
|
||||
| `tier_used` | text | AI model used for extraction (e.g. `gemini-2.5-flash`) |
|
||||
| `event_created` | bool | Whether a Google Calendar reminder was created for payment due date |
|
||||
|
||||
**Deduplication**: unique index on `(bank_name, account_number, billing_end_date)` prevents re-ingestion of the same period. `paperless_doc_id` has a separate unique index for Paperless-linked documents.
|
||||
|
||||
**Credit card detection**: `statement_type ILIKE '%card%'`
|
||||
|
||||
---
|
||||
|
||||
### `transactions`
|
||||
One row per line item within a statement. Cascade-deleted when the parent statement is deleted.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `statement_id` | int FK → `statements` (nullable) | Parent statement; NULL for manually-entered transactions |
|
||||
| `owner_id` | int FK → `participants` (nullable) | Owner for manual transactions (no statement); statement-linked transactions derive owner from `statements.owner_id` |
|
||||
| `transaction_date` | date | Date of transaction |
|
||||
| `description` | text | Raw description from the statement |
|
||||
| `amount` | numeric | Original amount in statement currency |
|
||||
| `amount_aud` | numeric | AUD-converted amount (= amount if already AUD) |
|
||||
| `transaction_type` | text | `debit`, `credit`, `payment`, `refund`, `fee`, `interest`, `transfer` |
|
||||
| `merchant_name` | text | Raw merchant name extracted by Gemini |
|
||||
| `merchant_normalized` | text | Cleaned/normalised merchant name (Gemini) |
|
||||
| `location` | text | Location if present on statement |
|
||||
| `foreign_currency_amount` | numeric | Original foreign amount if this was an FX transaction |
|
||||
| `foreign_currency_code` | text | Foreign currency code (e.g. `USD`) |
|
||||
| `category` | text | AI-assigned category (see category taxonomy below) |
|
||||
| `row_index` | int | Position in statement — used for deduplication |
|
||||
| `reconciled_with_id` | int FK → `transactions` (nullable) | Links a manually-entered transaction to its matching statement transaction after reconciliation |
|
||||
| `created_at` | timestamptz | When the row was inserted — the "import date". For reconciled transactions the UI shows the original manual/CSV `created_at`, not the statement's |
|
||||
|
||||
**Deduplication**: unique index on `(statement_id, transaction_date, description, amount, row_index)`.
|
||||
|
||||
**Analytics**: all spend queries use `amount_aud` for cross-currency consistency. Split-adjusted queries apply `amount_aud * share_percent / 100` where a split exists for the current user.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_overrides`
|
||||
User corrections to AI-extracted data. Stored separately to preserve the original extraction.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` (unique) | One override per transaction |
|
||||
| `merchant_normalized` | text | User-corrected merchant name |
|
||||
| `category_override` | text | User-corrected category |
|
||||
| `notes` | text | Free-text notes |
|
||||
|
||||
All analytics queries use `COALESCE(o.category_override, t.category)` and `COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name)` to prefer overrides over AI values.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_splits`
|
||||
Shared expense tracking — records that a transaction was split between participants.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` | The transaction being split |
|
||||
| `participant_id` | int FK → `participants` | Who shares in this transaction |
|
||||
| `share_percent` | numeric(5,2) | Their percentage (1–100) |
|
||||
| `settled` | bool | Whether this share has been settled |
|
||||
| `settled_at` | timestamptz | When it was settled |
|
||||
|
||||
A transaction can be split across multiple participants. The statement owner's own share is implicit (`100 - SUM(other shares)`). Analytics queries LEFT JOIN `transaction_splits` on `participant_id = current_user.id` — if no split row exists, the full amount belongs to the owner.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_tags`
|
||||
Many-to-many join between transactions and tags.
|
||||
|
||||
| Column | Type |
|
||||
|--------|------|
|
||||
| `transaction_id` | int FK → `transactions` |
|
||||
| `tag_id` | int FK → `tags` |
|
||||
|
||||
---
|
||||
|
||||
### `tags`
|
||||
User-defined coloured labels for ad-hoc transaction grouping beyond the fixed category taxonomy.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `name` | text (unique) | Tag name |
|
||||
| `color` | text | Hex colour (default `#6366f1`) |
|
||||
|
||||
---
|
||||
|
||||
### `participants`
|
||||
People who own statements or share expenses.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `name` | text (unique) | Display name |
|
||||
| `email` | text (unique) | Login identity — matched against `X-Forwarded-User` header |
|
||||
|
||||
---
|
||||
|
||||
### `account_owner_mappings`
|
||||
Persists `(bank, account_number) → owner` assignments so future ingestion auto-assigns the correct owner without manual intervention.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `bank_name` | text | |
|
||||
| `account_number` | text | |
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
|
||||
Written when a user reassigns a statement owner in the UI. Consulted by the N8N workflow on every new statement insert.
|
||||
|
||||
---
|
||||
|
||||
### `rules`
|
||||
Saved auto-categorisation rules. Applied in bulk via the Rules page.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | Rule belongs to this user |
|
||||
| `name` | text | Rule label |
|
||||
| `conditions` | jsonb | Array of `{field, operator, value}` — AND logic |
|
||||
| `actions` | jsonb | `{set_category, add_tag_ids, set_merchant}` |
|
||||
| `enabled` | bool | |
|
||||
| `priority` | int | Higher priority rules run first |
|
||||
|
||||
**Condition fields**: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`
|
||||
**Condition operators**: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`
|
||||
**Actions**: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`
|
||||
|
||||
---
|
||||
|
||||
### `split_payments`
|
||||
Records of actual cash settlements between participants.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `from_participant_id` | int FK → `participants` | Who paid |
|
||||
| `to_participant_id` | int FK → `participants` | Who received |
|
||||
| `amount` | numeric | Amount settled |
|
||||
| `payment_date` | date | Date of settlement |
|
||||
| `notes` | text | Optional note (e.g. "bank transfer") |
|
||||
| `linked_transaction_id` | int FK → `transactions` (nullable) | If the payment was itself a transaction |
|
||||
|
||||
---
|
||||
|
||||
### `expense_metadata`
|
||||
Enrichment records for non-statement expenses (email receipts, manual entries). Linked to a `transaction` if one exists; otherwise a standalone record awaiting reconciliation.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` (unique, nullable) | Linked transaction; NULL until reconciled |
|
||||
| `source` | text | Origin: `email`, `manual` |
|
||||
| `paperless_doc_id` | int | Paperless-NGX document ID |
|
||||
| `payment_method` | text | `credit_card`, `debit_card`, `paypal`, `afterpay`, `cash`, etc. |
|
||||
| `payment_method_detail` | text | Card last-4 or provider detail |
|
||||
| `order_reference` | text | Order/confirmation number |
|
||||
| `line_items` | jsonb | Array of `{description, qty, unit_price, total}` |
|
||||
| `merchant_normalized` | text | Canonical merchant for matching |
|
||||
| `amount` / `transaction_date` | numeric / date | Used for reconciliation matching when `transaction_id IS NULL` |
|
||||
| `extraction_model` | text | AI model used (`gemini-2.5-flash`) |
|
||||
|
||||
Partial index on `(merchant_normalized, transaction_date) WHERE transaction_id IS NULL` powers reconciliation queries.
|
||||
|
||||
---
|
||||
|
||||
### `rule_apply_runs`
|
||||
Audit log of bulk rule-apply operations. Each run captures which transactions were affected and a full snapshot for revert support.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
| `applied_at` | timestamptz | When the run executed |
|
||||
| `split_from` | date | Optional date filter used for this run |
|
||||
| `matched` | int | Number of rules matched |
|
||||
| `transactions_affected` | int | Number of transactions changed |
|
||||
| `reverted_at` | timestamptz | Set when run was reverted |
|
||||
| `snapshot` | jsonb | Pre-run state of all affected transactions |
|
||||
|
||||
---
|
||||
|
||||
### `budgets`
|
||||
Monthly spend targets per category. Stored but currently unused in the UI (replaced by the analytics/insights views).
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
| `category` | text | Category name |
|
||||
| `month` | date | Always first of month (e.g. `2026-03-01`) |
|
||||
| `amount_limit` | numeric | Spend target for that category/month |
|
||||
|
||||
---
|
||||
|
||||
## Category Taxonomy
|
||||
|
||||
Fixed set defined in `src/lib/categories.ts`. Applied by Gemini at ingestion and overridable by the user or rules engine:
|
||||
|
||||
`groceries` · `dining` · `transport` · `fuel` · `shopping` · `utilities` · `entertainment` · `travel` · `health` · `insurance` · `subscriptions` · `cash_advance` · `government` · `education` · `rent` · `home_goods` · `home_maintenance` · `transfers` · `income` · `investment` · `personal_care` · `pets` · `gifts` · `charity` · `other`
|
||||
|
||||
- **home_goods** — items purchased for the house (appliances, furniture, kitchenware, electronics)
|
||||
- **home_maintenance** — services on the property (cleaning, mowing, repairs)
|
||||
|
||||
**Committed spend** (Insights page): `rent`, `utilities`, `insurance`, `subscriptions`
|
||||
**Excluded from spend analytics**: `transfers`, `investment`
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes require authentication via `X-Forwarded-User` header (set by Traefik). Responses are always scoped to the authenticated user's `owner_id`.
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| GET | `/api/statements` | All statements for current user |
|
||||
| GET / PATCH | `/api/statements/[id]` | Get statement; PATCH to reassign owner (also writes `account_owner_mappings`) |
|
||||
| GET | `/api/transactions` | Paginated transactions. Filters: `from`, `to`, `categories`, `bank_names`, `tag_ids`, `transaction_types`, `search`, `statement_id`, `amount_min`, `amount_max`, `has_split` (`yes`/`no`). Sort: `sort_by` (`transaction_date`\|`amount`\|`created_at`), `sort_dir` (`asc`\|`desc`) |
|
||||
| POST | `/api/transactions` | Create a manual transaction (no statement) |
|
||||
| GET / PATCH | `/api/transactions/[id]` | Get transaction; PATCH to upsert override (category, merchant, notes) |
|
||||
| GET / POST | `/api/transactions/[id]/splits` | List or create splits on a transaction |
|
||||
| GET / POST | `/api/transactions/[id]/tags` | List or apply tags to a transaction |
|
||||
| POST | `/api/transactions/bulk` | Bulk update category/merchant across multiple transactions |
|
||||
| POST | `/api/transactions/reconcile` | Link manual transactions to statement transactions; copies overrides, tags, splits across |
|
||||
| GET | `/api/analytics/monthly` | Split-adjusted monthly spend by category + income + investments. Params: `months` (1–24, default 6) |
|
||||
| GET | `/api/analytics/subscriptions` | Recurring charge detection — merchants with ≥3 occurrences at consistent intervals |
|
||||
| GET | `/api/analytics/fees` | Fees and interest from statement summaries + individual fee/interest transactions |
|
||||
| GET | `/api/shared-transactions` | Transactions with active splits; sorted client-side by date/imported/amount in the UI |
|
||||
| POST | `/api/splits/settle` | Mark a split as settled |
|
||||
| GET / POST | `/api/split-payments` | List or record cash settlements between participants |
|
||||
| GET / POST | `/api/participants` | List participants; POST to create (with optional `email`) |
|
||||
| GET | `/api/participants/[id]/balance` | Net balance owed by/to a specific participant |
|
||||
| GET | `/api/participants/balances` | All participant balances |
|
||||
| GET / POST | `/api/rules` | List or create rules |
|
||||
| PATCH / DELETE | `/api/rules/[id]` | Update or delete a rule |
|
||||
| POST | `/api/rules/apply` | Run all enabled rules against all transactions; returns `{matched, transactions_affected}` |
|
||||
| GET / POST | `/api/budgets` | List budgets for a month (`?month=YYYY-MM`); upsert budget |
|
||||
| DELETE | `/api/budgets/[id]` | Delete a budget |
|
||||
| GET | `/api/merchants` | Merchant name autocomplete suggestions |
|
||||
| GET | `/api/me` | Current user info derived from `X-Forwarded-User` header |
|
||||
| GET / POST | `/api/tags` | List or create tags |
|
||||
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
|
||||
|
||||
**Machine routes** — shared-secret `x-ingest-token` header instead of `X-Forwarded-User`; each fails closed when its token is unset:
|
||||
|
||||
| Method | Route | Token | Description |
|
||||
|--------|-------|-------|-------------|
|
||||
| POST / PATCH | `/api/orders/ingest` | `ORDER_INGEST_TOKEN` | n8n order receipts → transactions (see smarthome CLAUDE.md "Order Processor") |
|
||||
| POST | `/api/receipts/ingest` | `RECEIPT_INGEST_TOKEN` | pantry-app confirmed grocery receipts → one transaction per tender leg (ING-10) |
|
||||
| POST | `/api/intake` | `INTAKE_TOKEN` | **Intake router** (2026-08-21): multipart `source` (`uber_eats\|doordash\|coles\|woolworths\|other`), `shared_by`, `batch_id` (uuid), 1–6 `file`s (image / PDF / text). Persists `intake_batches`, extracts once via LiteLLM `cloud-budget` (PDF → text via `unpdf`), matches `(platform, merchant, date, total)` exactly, then enriches the matched order's `line_items` (food), forwards the original files to pantry `POST /api/capture` (grocery), or replies with candidates / "Groceries or a meal?" / needs-header / held / duplicate. Returns `{kind, status, transaction_id?, capture_id?, candidates?, slack_blocks, text}` |
|
||||
| PUT | `/api/transactions/[id]/line-items` | `INTAKE_TOKEN` or `RECEIPT_INGEST_TOKEN` | Idempotent replace of `expense_metadata.line_items` for `transaction_id = :id OR matched_transaction_id = :id`; body `{line_items, source: 'intake'\|'pantry', batch_id?, platform?, replace: true}`; creates the metadata row if absent, never a transaction (404). Returns `{previous_line_items, line_items}` — pantry calls it on every confirm and with `previous_line_items` on undo |
|
||||
| POST | `/api/slack/interactive` | `ORDER_INGEST_TOKEN` + Slack signature | Order-nudge buttons and the intake taps (`intake_use_candidate`, `intake_create`, `intake_grocery`, `intake_meal`) |
|
||||
|
||||
Env for the intake router: `INTAKE_TOKEN`, `LITELLM_BASE_URL` + `LITELLM_KEY` (OpenAI-compatible, alias `cloud-budget`), `PANTRY_BASE_URL` (+ the existing `RECEIPT_INGEST_TOKEN` as pantry's machine auth), optional `INTAKE_FILE_DIR` (original files by sha256, default `/app/data/intake` — a later "Groceries" tap re-sends them) and `FINANCE_PUBLIC_URL` (the card's View link, default `https://finance.bosecamp.com`). Design: smarthome `plans/intake-router-slice-plan.md` §11.
|
||||
|
||||
---
|
||||
|
||||
## Ingestion Pipeline
|
||||
|
||||
```
|
||||
Paperless-NGX
|
||||
└─ documents tagged "Bank Statement" + "Credit Card" (without "cc-processor")
|
||||
│
|
||||
▼
|
||||
N8N workflow — polls every 5 minutes (workflow ID: FysADdFwEtwONQl4)
|
||||
│
|
||||
├─ Duplicate check: SELECT WHERE paperless_doc_id = <id>
|
||||
│ └─ Already processed → skip, mark in Paperless
|
||||
│
|
||||
├─ Download PDF binary from Paperless API
|
||||
│
|
||||
├─ Gemini 2.5 Flash — PDF → structured JSON
|
||||
│ responseSchema: { summary: {...}, transactions: [...] }
|
||||
│ timeout: 180s, retryOnFail: 3×, delay: 30s
|
||||
│
|
||||
├─ Parse & normalise
|
||||
│ account_number: strip spaces
|
||||
│ bank_name: title-case
|
||||
│ FX rate: fetch live from open.er-api.com if non-AUD
|
||||
│
|
||||
├─ Statement exists? (bank + account + billing_end_date)
|
||||
│ └─ Duplicate → skip, mark in Paperless
|
||||
│
|
||||
├─ New bank? → Slack approval gate (human confirms before insert)
|
||||
│
|
||||
├─ Lookup account_owner_mappings → resolve owner_id (default: 1 = "Me")
|
||||
│
|
||||
├─ INSERT statements + transactions
|
||||
│
|
||||
├─ Google Calendar reminder for payment_due_date (credit cards)
|
||||
│
|
||||
└─ Paperless: PATCH document to add "cc-processor" tag
|
||||
```
|
||||
|
||||
N8N workflow JSON: `docker/automation/workflows/cc-statement-processor-paperless.json` in the smarthome repo.
|
||||
|
||||
---
|
||||
|
||||
## Schema Migrations
|
||||
|
||||
Located in `prisma/migrations/`. Applied manually against the running container:
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal \
|
||||
< prisma/migrations/<migration>/migration.sql
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
| Migration | What it adds |
|
||||
|-----------|-------------|
|
||||
| `0001_init` | `statements`, `transactions`, `participants` |
|
||||
| `0002_splits` | `transaction_splits` |
|
||||
| `0003_owner_segregation` | `owner_id` on statements, `account_owner_mappings`, `email` on participants |
|
||||
| `0004_tags` | `tags`, `transaction_tags` |
|
||||
| `0005_rules` | `rules` |
|
||||
| `0006_budgets` | `budgets` |
|
||||
| `0007_cashflow` | `amount_aud`, `exchange_rate_to_aud` on transactions; `exchange_rate_to_aud` on statements |
|
||||
| `0008_my_share_percent` | `my_share_percent` on `transaction_overrides` |
|
||||
| `0009_split_payments` | `split_payments` |
|
||||
| `0010_csv_import_reconcile` | `reconciled_with_id`, CSV import support |
|
||||
| `0011_trips` | `trips`, `trip_id` on `transaction_overrides` (catch-up — was applied directly) |
|
||||
| … | |
|
||||
| `0034_intake_batches` | `intake_batches` (one row per `/api/intake` upload, written before extraction) and `intake_merchant_kinds` (the remembered "Groceries or a meal?" answer per merchant) |
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
|
||||
> `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement.
|
||||
> `reconciled_with_id` on transactions, `expense_metadata`, `rule_apply_runs`, `split_payments` were added directly and are covered by the Prisma schema but lack individual migration files.
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
---
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Known Gaps / TODOs
|
||||
## Learn More
|
||||
|
||||
### Payment Provider tracking
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
Currently `merchant_normalized` conflates the *payment provider* with the *merchant*. Transactions processed through PayPal, Afterpay, Zip, Alipay, etc. end up with the provider as the merchant when the real merchant can't be recovered.
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
**What's been done so far:**
|
||||
- PayPal entries that embed the merchant name (e.g. `PAYPAL *BUNNINGSGRO`) were cleaned up — the real merchant was extracted during the March 2026 consolidation pass.
|
||||
- Pure PayPal/Afterpay/Zip entries where the merchant is unrecoverable were left as-is.
|
||||
- A one-time SQL consolidation pass normalised ~50 merchant name variant groups (March 2026).
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
**Remaining work:**
|
||||
1. **DB migration**: `ALTER TABLE transactions ADD COLUMN payment_provider text` and same on `transaction_overrides`.
|
||||
2. **Gemini prompt**: add `payment_provider` to the `responseSchema` so the AI extracts it separately (`"PayPal"`, `"Afterpay"`, `"Zip"`, `null`, etc.) — the raw bank description usually contains enough signal.
|
||||
3. **Backfill**: for existing transactions, derive `payment_provider` from `merchant_name` patterns (`PAYPAL *`, `AFTERPAY`, `ZIP/ZIPPAY`, `BPAY`).
|
||||
4. **App**: surface `payment_provider` as a filter/column in the transactions view; exclude payment providers from merchant analytics so they don't inflate the merchant list.
|
||||
## Deploy on Vercel
|
||||
|
||||
---
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
## Agent Access (MCP)
|
||||
|
||||
LLM agents read this data through the read-only `postgres-personal` MCP server (7 typed
|
||||
finance tools + guarded `adhoc_query`, `agent_ro` role, 100-row cap, `mcp_query_log`
|
||||
audit). It's a stdio server, so any MCP client can use it — Hermes, Claude Code, Codex,
|
||||
Claude Desktop — locally via `docker exec` or remotely over SSH.
|
||||
|
||||
**→ [docs/agent-access.md](docs/agent-access.md)** — architecture, tool + view reference,
|
||||
per-client setup, and what changing the schema means for the agent views.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Runs as a Docker container alongside the rest of the home lab stack.
|
||||
|
||||
**Push-to-deploy (default, since 2026-07-19)**: pushing to `main` on Gitea fires a
|
||||
webhook to Komodo's `deploy-finance` Procedure, which redeploys the `finance` stack
|
||||
with `--build`. CI (Gitea Actions) runs lint + unit tests on the same push.
|
||||
|
||||
**Manual fallback** if Komodo is unavailable:
|
||||
|
||||
```bash
|
||||
# From smarthome repo root
|
||||
docker compose --env-file docker/common.env --env-file docker/finance/.env \
|
||||
-f docker/finance/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
The container is only reachable via Traefik (`https://finance.bosecamp.com`, forward-auth
|
||||
sets `X-Forwarded-User`) plus a `127.0.0.1`-bound host port for on-box debugging — the
|
||||
app trusts the `X-Forwarded-User` header, so it must never be directly reachable from
|
||||
the LAN.
|
||||
|
||||
The container uses Next.js standalone output. `@prisma/adapter-pg` and `pg` are listed in `serverExternalPackages` in `next.config.ts` to ensure they are included in the standalone bundle.
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# Agent Access — querying the finance data from an LLM agent
|
||||
|
||||
How an AI agent (Hermes, Claude Code, Codex, Claude Desktop, …) reads the finance
|
||||
database. **Read-only, audited, row-capped.** Agents never write to `personal`.
|
||||
|
||||
The read path is an MCP server — `postgres-personal` — that lives in the
|
||||
[`personal-agent-gateway`](ssh://git@localhost:2222/siddharthd/personal-agent-gateway.git)
|
||||
repo at `mcp-servers/postgres-personal/`. This app owns the data; that repo owns the
|
||||
server. Keep both in mind when changing the schema (see [Schema changes](#schema-changes-that-affect-agents)).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
agent client (Hermes / Claude Code / Codex)
|
||||
│ stdio (JSON-RPC, MCP)
|
||||
▼
|
||||
server.py ── guardrails.validate_select (SQLGlot: single SELECT, LIMIT ≤ 100)
|
||||
├── PG_DSN_RO → role agent_ro (SELECT only, statement_timeout 5s) → queries
|
||||
└── PG_DSN_LOG → role agent_log (INSERT into mcp_query_log only) → audit
|
||||
```
|
||||
|
||||
Both roles, the audit table, and the five analysis views are created by
|
||||
`smarthome/personal-agent/migrations/006_agent_read_role_views.sql` — **not** by this
|
||||
repo's Prisma migrations. Passwords are supplied at apply time from Infisical
|
||||
(`homelab-w-ae9 / prod / /agent-gateway`), never stored in the SQL file.
|
||||
|
||||
Defence in depth, in order:
|
||||
|
||||
1. `agent_ro` has no write grants at all — the DB refuses writes regardless of the SQL.
|
||||
2. `validate_select` parses every SQL string (typed tools and `adhoc_query` alike) and
|
||||
rejects anything that isn't one plain `SELECT`, plus `pg_sleep`/`pg_read_file`/
|
||||
`dblink`/`lo_import`/`lo_export`. It rewrites `LIMIT` to ≤ 100.
|
||||
3. Third-party text columns (`description`, `location`, `order_reference`, `line_items`)
|
||||
come back wrapped in `<<untrusted-data>>…<</untrusted-data>>` so the runtime's system
|
||||
prompt can treat statement/receipt text as data, never instructions.
|
||||
4. Every call — accepted or rejected — is inserted into `mcp_query_log`
|
||||
(`tool, args, sql, row_count, duration_ms, error`).
|
||||
|
||||
Audit review:
|
||||
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal -c \
|
||||
"SELECT at, tool, row_count, duration_ms, error FROM mcp_query_log ORDER BY at DESC LIMIT 20"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it answers |
|
||||
|---|---|
|
||||
| `query_statements` | Statements by bank / account number / `billing_end_date` range |
|
||||
| `query_transactions` | Transaction search: merchant, category, date range, amount range, description substring |
|
||||
| `get_spending_summary` | Outflow for one month (`YYYY-MM`) or date range, grouped by category or merchant |
|
||||
| `get_spending_comparison` | Two months side by side, sorted by absolute delta — what drove the change |
|
||||
| `get_upcoming_payments` | Statements with `payment_due_date` in the next N days (1–90, default 14) |
|
||||
| `get_recurring_spend` | Recurring-payment candidates (heuristic — expect false positives) |
|
||||
| `adhoc_query` | One read-only `SELECT` when the typed tools can't express the question |
|
||||
|
||||
Views available to `adhoc_query` (all `SELECT`-granted to `agent_ro`):
|
||||
|
||||
| Relation | Grain |
|
||||
|---|---|
|
||||
| `transaction_search` | Flat per-transaction surface: transaction ⟕ statement identity ⟕ `expense_metadata`. The right default for search. |
|
||||
| `merchant_monthly_spend` | month × merchant → txn_count, total_spend, avg_amount |
|
||||
| `category_monthly_spend` | month × category → txn_count, total_spend |
|
||||
| `cashflow_monthly` | month → total_outflow, total_inflow, net, txn_count |
|
||||
| `recurring_candidates` | merchant × amount bucket → occurrences, cadence, last_seen, next_expected |
|
||||
|
||||
Base tables (`transactions`, `statements`, `transaction_splits`, …) are also readable —
|
||||
see [README → Data Model](../README.md#data-model).
|
||||
|
||||
Semantics baked into the views:
|
||||
|
||||
- outflows = `transaction_type IN ('debit','fee','interest')`; inflows = `('payment','credit','refund')`
|
||||
- amount prefers `amount_aud` (FX-normalised) over `amount`
|
||||
- merchant prefers `merchant_normalized` → `merchant_name` → first 60 chars of `description`
|
||||
|
||||
### Two caveats worth knowing
|
||||
|
||||
- **No owner scoping.** Unlike the app's API routes (`COALESCE(t.owner_id, s.owner_id) = $1`),
|
||||
the agent views expose *all* owners' rows. Fine while the only consumer is the household's
|
||||
own assistant; it must change before any agent is exposed to a second person.
|
||||
- **No `transaction_overrides` merge.** The views read `t.category` / `t.merchant_normalized`
|
||||
directly, so manual corrections made in the UI are not reflected. The app's own queries use
|
||||
`COALESCE(o.category_override, t.category)`. Numbers from an agent can therefore differ
|
||||
slightly from the same figure in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Connecting a client
|
||||
|
||||
The server speaks **stdio MCP**. There is no network listener, so a client connects by
|
||||
*executing* it. The easiest correct way is to exec it inside the `hermes` container: the
|
||||
repo is bind-mounted there (`/mnt/user/projects/personal-agent-gateway` → `/opt/data/repo`),
|
||||
the venv and deps already exist, `PG_DSN_RO`/`PG_DSN_LOG` are already in the container env,
|
||||
and `postgres-personal` resolves on `networks_internal`.
|
||||
|
||||
The canonical command, used by every recipe below:
|
||||
|
||||
```bash
|
||||
docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Smoke-test it before wiring a client in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' \
|
||||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
|
||||
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||
| docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py 2>/dev/null | head -2
|
||||
```
|
||||
|
||||
### Claude Code (on the Unraid host)
|
||||
|
||||
```bash
|
||||
claude mcp add postgres-personal -s user -- \
|
||||
docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Then `/mcp` in a session to confirm the seven tools are listed.
|
||||
|
||||
### Claude Code / Claude Desktop (from a laptop, over SSH)
|
||||
|
||||
Same command, tunnelled — stdio doesn't care what carries it:
|
||||
|
||||
```bash
|
||||
claude mcp add postgres-personal -s user -- \
|
||||
ssh unraid docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Needs key-based SSH to the host (Tailscale or LAN) and a non-interactive shell. For
|
||||
Claude Desktop, the same `command` / `args` go in `claude_desktop_config.json` under
|
||||
`mcpServers`.
|
||||
|
||||
### Codex CLI
|
||||
|
||||
`~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.postgres-personal]
|
||||
command = "docker"
|
||||
args = [
|
||||
"exec", "-i", "hermes",
|
||||
"/opt/data/venvs/pgp/bin/python",
|
||||
"/opt/data/repo/mcp-servers/postgres-personal/server.py",
|
||||
]
|
||||
```
|
||||
|
||||
(Prefix `args` with `["unraid", "docker", …]` and set `command = "ssh"` for the remote case.)
|
||||
|
||||
### Hermes
|
||||
|
||||
Already wired — `mcp_servers.postgres-personal` in `/mnt/user/appdata/docker/hermes/config.yaml`
|
||||
(template: `personal-agent-gateway/spike/hermes/config.yaml.example`). It runs the same
|
||||
venv and script directly rather than via `docker exec`, since it *is* the container.
|
||||
|
||||
### Running the server outside `hermes`
|
||||
|
||||
Only needed if `hermes` is down or you want isolation. Two constraints: Python ≥ 3.11 with
|
||||
`mcp`, `psycopg[binary]`, `sqlglot`; and network reach to `postgres-personal`, which is
|
||||
**not** published to the host — it only resolves on the `networks_internal` Docker network.
|
||||
So run it in a container on that network, passing the DSNs:
|
||||
|
||||
```bash
|
||||
docker run -i --rm --network networks_internal \
|
||||
--env-file /mnt/user/appdata/docker/hermes/.env \
|
||||
-v /mnt/user/projects/personal-agent-gateway:/app \
|
||||
personal-agent-gateway python /app/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
A bare host venv would need the container's current bridge IP in the DSN instead of the
|
||||
hostname, which breaks on every recreate — don't.
|
||||
|
||||
### Secrets
|
||||
|
||||
`PG_DSN_RO` and `PG_DSN_LOG` live in `/mnt/user/appdata/docker/hermes/.env` (canonical
|
||||
copies in Infisical `/agent-gateway`). Never paste them into a client config, a
|
||||
`claude mcp add` command line, or this repo — the `docker exec` recipes above inherit them
|
||||
from the container, which is precisely why they're preferred.
|
||||
|
||||
---
|
||||
|
||||
## Direct SQL (humans and one-off scripts)
|
||||
|
||||
For ad-hoc analysis where an agent isn't in the loop, skip MCP:
|
||||
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal -c \
|
||||
"SELECT * FROM cashflow_monthly ORDER BY month DESC LIMIT 12"
|
||||
```
|
||||
|
||||
That's the full-privilege `personal` role — no guardrail, no row cap, no audit row. Use
|
||||
`agent_ro` if you want the same safety envelope an agent gets.
|
||||
|
||||
---
|
||||
|
||||
## Making this available more widely
|
||||
|
||||
Today: any MCP client that can run a subprocess can use it (all of the above), and any
|
||||
machine that can SSH to the host can too. That covers Claude Code, Claude Desktop, Codex,
|
||||
and Hermes without a code change.
|
||||
|
||||
What it would take to go further:
|
||||
|
||||
- **Remote clients without SSH** — FastMCP supports `streamable-http`; `mcp.run()` in
|
||||
`server.py` would become `mcp.run(transport="streamable-http")` behind Traefik with
|
||||
forward-auth. Small code change, real security decision: it puts the household's
|
||||
financial history behind a network listener. Not done, deliberately.
|
||||
- **A second user** — requires owner scoping in the views (see caveats above) plus an
|
||||
identity the server can bind to; the DSN carries no user identity today.
|
||||
- **Write access** — out of scope. The write path is the app's API routes with
|
||||
`getCurrentUser()`; `agent_ro` should stay SELECT-only.
|
||||
|
||||
---
|
||||
|
||||
## Schema changes that affect agents
|
||||
|
||||
The views in `006_agent_read_role_views.sql` read `transactions`, `statements`, and
|
||||
`expense_metadata` directly. If you rename or drop a column those views use, re-apply the
|
||||
migration in the smarthome repo — a Prisma migration here won't do it, and the views will
|
||||
either break or silently go stale:
|
||||
|
||||
The file is idempotent (`CREATE OR REPLACE VIEW`, guarded role creation) — re-applying is
|
||||
safe. It needs the `agent.ro_password` / `agent.log_password` settings supplied at apply
|
||||
time from Infisical; follow the apply instructions in the header of the SQL file itself
|
||||
(`/mnt/user/appdata/smarthome/personal-agent/migrations/006_agent_read_role_views.sql`)
|
||||
rather than piping it in blind.
|
||||
|
||||
New tables are readable by `agent_ro` automatically (`ALTER DEFAULT PRIVILEGES`), but new
|
||||
*views* need an explicit `GRANT SELECT … TO agent_ro`.
|
||||
@@ -1,211 +0,0 @@
|
||||
# Monthly expense baseline and emergency reserve
|
||||
|
||||
Analysis run 2026-07-26. **One-off analysis, not a feature** — nothing in the app
|
||||
computes these numbers. Read "Reproducing this" before trusting a restated figure.
|
||||
|
||||
The question: how much should be held in reserve to cover 6–12 months of expenses?
|
||||
|
||||
## Answer
|
||||
|
||||
| Scenario | $/mo | 6 months | 12 months |
|
||||
|---|---:|---:|---:|
|
||||
| Survival — contracted loan repayment, essentials only | 3,040 | 18,250 | 36,500 |
|
||||
| **Realistic — contracted loan, + dining and charity** | **4,140** | **24,800** | **49,600** |
|
||||
| Status quo — keep overpaying the loan, normal life ex-travel | 5,557 | 33,500 | 67,000 |
|
||||
|
||||
Use the middle row. The survival row assumes dining is cut to zero and stays
|
||||
there, which is not a plan anyone executes for six months.
|
||||
|
||||
Against that, liquidity already available (statements 133 and 131, 2026-06-30):
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Loan redraw | 81,017.42 |
|
||||
| Offset balance | 8,753.00 |
|
||||
| **Accessible** | **89,770.42** |
|
||||
|
||||
That is 3.6× the six-month target and 1.8× the twelve-month one. Redraw grew
|
||||
$62,387.17 → $81,017.42 across the last two loan statements, matching the
|
||||
overpayment rate — the money spent killing the loan faster is still reachable.
|
||||
|
||||
**Caveat on counting redraw as the reserve.** It is available at AMP's
|
||||
discretion, and lenders reduce or freeze it exactly when a borrower looks
|
||||
distressed — which is when it would be needed. The rate also moved 5.54% → 6.29%
|
||||
between the two statements, so redrawn funds cost more than they did. Hold some
|
||||
genuine cash; it does not need to be $50,000.
|
||||
|
||||
## This is *your* outgoings, not household spend
|
||||
|
||||
The app only sees accounts that get imported. Sonu's own spending on the
|
||||
household is invisible to it. Grocery spend reads as ~$300/mo gross on 4–10
|
||||
transactions, which is implausible for a household and is partly explained by her
|
||||
paying from her own account.
|
||||
|
||||
For "how much reserve do **I** need" that blind spot does not matter — your own
|
||||
outgoings is the correct measure. Do not relabel these figures as household
|
||||
totals; they are not, and they would be wrong by an unknown amount.
|
||||
|
||||
## The loan has two floors
|
||||
|
||||
This is the largest single lever and the reason there are three scenarios.
|
||||
|
||||
| | $/fortnight | $/mo annualised | Your 50% |
|
||||
|---|---:|---:|---:|
|
||||
| Contracted minimum | 1,190.54 | 2,579.50 | **1,290** |
|
||||
| Actual direct debit | 2,500.00 | 5,416.67 | **2,708** |
|
||||
| Voluntary overpayment | 1,309.46 | 2,837.17 | 1,419 |
|
||||
|
||||
Dropping to contracted cuts your loan cost by $1,418/mo. Sonu's obligation is a
|
||||
fixed 50% of the repayment (see CLAUDE.md → "The shared loan"), so it falls with
|
||||
it. Her rate returned to $1,250/fortnight in July 2026 after the reduced $750
|
||||
period during her leave.
|
||||
|
||||
## Baseline composition
|
||||
|
||||
Built from **Feb–Jun 2026** — the months where split data is trustworthy — with
|
||||
annual items annualised over 12 rather than divided by the 5-month sample.
|
||||
|
||||
| Essential | $/mo | Note |
|
||||
|---|---:|---|
|
||||
| Loan (contracted, your 50%) | 1,290 | 2,708 at the current actual rate |
|
||||
| Transport | 326 | |
|
||||
| Insurance | 280 | annualised; your 55% |
|
||||
| Utilities | 278 | shared energy/water + personal mobile |
|
||||
| Subscriptions | 204 | shared household + personal/AI |
|
||||
| Groceries | 200 | see the blind-spot note above |
|
||||
| Card + package fees | 175 | annualised — see below |
|
||||
| Health | 167 | |
|
||||
| Rates + rego | 122 | annualised, your share |
|
||||
| **Essential** | **3,042** | |
|
||||
| + dining 675, charity 422 | 4,139 | charity is a Smith Family sponsorship commitment |
|
||||
| + typical shopping | ~4,800 | median 657, **not** the 1,709 mean |
|
||||
|
||||
**Travel is excluded throughout.** At $3,153/mo of your share even post-cutover it
|
||||
would roughly double every figure, and it is the first thing that stops.
|
||||
|
||||
## Four corrections the raw data needed
|
||||
|
||||
Any restatement that skips these will be wrong. None are fixed in the data yet.
|
||||
|
||||
**Micro-investing counted as spend.** Raiz ($9,406 / 27 rows), Vanguard Super
|
||||
($500) and moomoo ($300) sit in `other` as debits — $10,206/yr, ~$850/mo of
|
||||
phantom spend. These belong in `investment`, which is already excluded.
|
||||
|
||||
**Incoming money counted as negative spend.** 17 rows in `other` typed `credit`
|
||||
($5,622 in the window). `SPEND_SIGNED` negates credits so refunds cancel
|
||||
purchases, but these are not refunds — they are money arriving. June 2025 shows
|
||||
*minus* $7,814 of total spend because two Wise credits of ~$16.7k each landed in
|
||||
`other`.
|
||||
|
||||
**`government` is two unrelated things.** $25,554 of ATO payments (one annual
|
||||
bill, routed through Zen B2B and RewardPay to earn points) versus $2,054 council
|
||||
rates and $875 rego. Tax is not a monthly living cost and falls with income
|
||||
anyway; rates and rego are non-negotiable. Splitting them moves this line from
|
||||
$2,411/mo to $244/mo.
|
||||
|
||||
**`fees` is mostly annual.** Of $2,599 post-cutover, $1,750 is an annual card fee
|
||||
and $349 a loan package annual fee. Recurring is ~$175/mo annualised, not the
|
||||
$520/mo the 5-month mean implies.
|
||||
|
||||
## Splits: what is trustworthy and what is not
|
||||
|
||||
Splits exist in this app from **2026-01-09** only; before that they were tracked
|
||||
in SplitMyExpenses. So a trailing-12-month per-person series splices six months
|
||||
of *gross* onto six months of *net* and is not a series at all. Use Feb–Jun 2026.
|
||||
|
||||
**Partial split coverage within a category is usually correct, not a gap.** This
|
||||
was misdiagnosed once during the analysis. Verified composition:
|
||||
|
||||
| Category | Your share | Split | Unsplit |
|
||||
|---|---:|---|---|
|
||||
| utilities | 69% | Globird, OVO, GWW, home telecoms | Telstra, Vodafone, Optus, JB Hi-Fi Mobile |
|
||||
| subscriptions | 91% | Uber One, Amazon Prime, OnePass | Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero, Billdu, Spotify, Patreon |
|
||||
| fees | 100% | — | credit card fees are personal |
|
||||
| charity | 100% | — | personal commitment |
|
||||
|
||||
Only *shared* utilities and subscriptions are split. Mobile bills, AI
|
||||
subscriptions and card fees are personal and correctly sit at 100%. Do not
|
||||
"fix" these ratios.
|
||||
|
||||
## Known weak spots
|
||||
|
||||
- **`other` is $424–773/mo of your share and unclassified.** The single biggest
|
||||
lever on accuracy. Clearing it with the rule preview
|
||||
(`GET /api/rules/[id]/matches`) improves every other view at the same time.
|
||||
- **Five months is a short sample**, and it contains the Europe trip and the ATO
|
||||
bill. Both are excluded, but they crowd out the ordinary months. The
|
||||
SplitMyExpenses CSVs would stretch it to 18 months — worth having, not
|
||||
blocking, since only an *aggregate ratio per category* is needed, not row
|
||||
matching, so the combined-transaction problem does not bite.
|
||||
- **`/api/analytics/monthly` does not filter `reconciled_with_id IS NOT NULL`.**
|
||||
48 rows are double-counted app-wide. Small, but real, and unrelated to this
|
||||
analysis. The queries below do filter it.
|
||||
|
||||
## Reproducing this
|
||||
|
||||
Working queries are not checked in; they were run ad hoc against
|
||||
`postgres-personal`. The shape that matters:
|
||||
|
||||
```sql
|
||||
-- Per-category monthly distribution, your share, Feb-Jun 2026.
|
||||
-- Mirrors src/lib/analytics-sql.ts, plus the corrections above.
|
||||
WITH s AS (
|
||||
SELECT to_char(t.transaction_date,'YYYY-MM') m,
|
||||
COALESCE(o.category_override, t.category, 'other') cat,
|
||||
SUM((CASE WHEN t.transaction_type IN ('refund','credit') THEN -1 ELSE 1 END)
|
||||
* (CASE WHEN t.interest_amount IS NOT NULL
|
||||
THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
* COALESCE(ts.share_percent, o.my_share_percent,
|
||||
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||
WHERE x.transaction_id = t.id
|
||||
AND x.participant_id <> 1), 0)) / 100)::numeric(12,2) amt
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = 1
|
||||
LEFT JOIN statements st ON st.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, st.owner_id) = 1
|
||||
AND t.reconciled_with_id IS NULL -- see weak spots
|
||||
AND ((t.transaction_type IN ('debit','fee','interest')
|
||||
OR t.interest_amount IS NOT NULL)
|
||||
OR t.transaction_type IN ('refund','credit'))
|
||||
AND COALESCE(o.category_override, t.category, 'other')
|
||||
NOT IN ('transfers','investment','income')
|
||||
-- correction 1: misfiled investments
|
||||
AND COALESCE(t.merchant_normalized, t.merchant_name, '')
|
||||
!~* '^(Raiz|Vanguard Super|moomoo)'
|
||||
-- correction 2: incoming money miscategorised as 'other' credits
|
||||
AND NOT (COALESCE(o.category_override, t.category, 'other') = 'other'
|
||||
AND t.transaction_type = 'credit')
|
||||
AND t.transaction_date >= '2026-02-01' AND t.transaction_date < '2026-07-01'
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT cat, count(*) mths, round(avg(amt),0) mean,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY amt)::numeric, 0) median
|
||||
FROM s GROUP BY cat ORDER BY mean DESC;
|
||||
```
|
||||
|
||||
Use the **median** for anything recurring monthly and the **annualised total** for
|
||||
lumpy-but-certain items (insurance, rates, rego, annual card fees). Means are
|
||||
badly skewed here — shopping is 1,709 mean against 657 median.
|
||||
|
||||
Corrections 3 and 4 (`government`, `fees`) are not expressible as filters; they
|
||||
need the category split by merchant, done by hand above.
|
||||
|
||||
## If this becomes a feature
|
||||
|
||||
The blocker is `other`, not the split model — the split data held up under
|
||||
scrutiny. Sequence:
|
||||
|
||||
1. Recategorise Raiz / Vanguard Super / moomoo to `investment` (27 rows,
|
||||
unambiguous) via the rule preview.
|
||||
2. Split `government` so rates and rego separate from ATO. Probably a new
|
||||
category or a tag; `government` conflates a recurring obligation with an
|
||||
annual tax bill.
|
||||
3. Work `other` down with the rule preview.
|
||||
4. Then a baseline panel on Insights, which already carries the
|
||||
Regular/Occasional split this analysis is a stricter version of
|
||||
(`REGULAR_CATEGORIES` in `src/lib/categories.ts`).
|
||||
|
||||
A **liquidity vs baseline** view would be genuinely new rather than a restatement:
|
||||
`statements.redraw_available`, offset closing balance, and the
|
||||
contracted-vs-actual repayment gap are all in the schema and nothing reads them.
|
||||
@@ -1,246 +0,0 @@
|
||||
# Shared expenses and settlement
|
||||
|
||||
Status: **built and live**, as of 2026-07-28. The loan section at the end is
|
||||
still a proposal — nothing there is built.
|
||||
|
||||
This replaces the 2026-07-26 proposal. That document described three problems
|
||||
and proposed a `settlement_contexts` table to solve them. The problems were
|
||||
real; the table was not built, and the reasoning for not building it is
|
||||
recorded under [What was rejected](#what-was-rejected).
|
||||
|
||||
---
|
||||
|
||||
## The one rule
|
||||
|
||||
**Spend and owed are different questions asked of the same table, and the line
|
||||
between them is the cutover date, refined by `transaction_splits.settled`.**
|
||||
|
||||
`ACTIVE_OBLIGATION` is `ts.settled = false AND t.transaction_date >=
|
||||
'2026-01-09'`. Nothing before the cutover can be owed, because carryover
|
||||
transaction 2348 already carries the entire pre-cutover balance as one figure —
|
||||
so a split on an older transaction describes only *how an expense was shared*.
|
||||
That is what makes splitting history safe, and it is why the flag is a
|
||||
refinement rather than the guard: any delete-and-recreate write path resets a
|
||||
boolean, and one did.
|
||||
|
||||
| | Counts settled splits? | Why |
|
||||
|---|---|---|
|
||||
| **Spend** (`myShare`, `mySplitOf`) | **Yes** | Half a 2025 grocery shop was my expense whether or not the other half was ever repaid. |
|
||||
| **Owed** (balances, trip figures) | **No** | A discharged obligation is not outstanding. |
|
||||
|
||||
Getting this backwards in either direction is the failure the model exists to
|
||||
prevent. Filtering settled rows out of spend would re-inflate exactly the
|
||||
figures that importing settled history exists to correct.
|
||||
|
||||
The predicate is `ACTIVE_OBLIGATION` in `src/lib/analytics-sql.ts`.
|
||||
|
||||
## Two orthogonal axes
|
||||
|
||||
`settled` and `trip_id` answer different questions and neither implies the
|
||||
other:
|
||||
|
||||
- **`transaction_splits.settled`** — *is this obligation still live?*
|
||||
- **`split_payments.trip_id`** — *which tab does this payment settle?*
|
||||
NULL means the ongoing household tab.
|
||||
|
||||
A trip can be fully paid while the household tab runs a balance, and vice
|
||||
versa. Before migration 0022 there was one global pool and this could not be
|
||||
expressed, so every trip reported 100% unsettled forever — including trips paid
|
||||
in full.
|
||||
|
||||
## How settling up actually works
|
||||
|
||||
**By recording a payment.** There is deliberately no "mark settled" action
|
||||
anywhere in the app.
|
||||
|
||||
`settled` marks obligations discharged *outside* this app — the imported
|
||||
SplitMyExpenses history, whose repayments happened on a platform we no longer
|
||||
run and which therefore have no `split_payments` row here. A live obligation is
|
||||
settled by recording the payment, and the balance nets to zero on its own.
|
||||
|
||||
Doing both would subtract the settlement twice: the splits leave the sum *and*
|
||||
the payment is deducted, driving the balance negative by the amount repaid.
|
||||
|
||||
## What is built
|
||||
|
||||
| Piece | Where | Note |
|
||||
|---|---|---|
|
||||
| `settled` as the single balance gate | `ACTIVE_OBLIGATION` | Applied in both arms of the balances UNION and in the trip owed query |
|
||||
| Payment scope | `split_payments.trip_id` (migration 0022) | Household payments do not settle a trip, and vice versa |
|
||||
| Owner-scoped owed | `OWNER_SCOPE` in the trip owed query | Without it, a debt between the *other two* participants was reported as owed to the owner — $1,605.49 on Europe 2026 |
|
||||
| Direction on screen | `/trips/[id]`, `/shared` | all square / owes you / ahead — you owe them |
|
||||
| Historical splits | `scripts/split_csv_match.py` | 1,242 rows across 657 transactions, all `settled` |
|
||||
| Duplicate suppression | `transactions.superseded_by_id` (migration 0023) | 31 rows, $42,040.68 |
|
||||
| Overlap detection | `STATEMENT_OVERLAPS` → statements page | Red badge; catches the cause rather than the symptom |
|
||||
|
||||
## The historical import
|
||||
|
||||
The five SplitMyExpenses CSVs are the record of how expenses were shared before
|
||||
this app existed. 676 of 1,536 shareable rows matched (44%), and 1,242 split
|
||||
rows were written as `settled = true`.
|
||||
|
||||
**The deliverable is historical spend, not balances.** $35,259 left my spend —
|
||||
$13,088 in 2024 and $22,117 in 2025 — because a $200 grocery shop that was
|
||||
always half hers no longer reads as $200 of mine. Balances were byte-identical
|
||||
before and after, which is the assertion that mattered.
|
||||
|
||||
Three things the matcher has to get right, each of which has bitten:
|
||||
|
||||
1. **Date format is decided per file.** The household export writes D/M/YYYY and
|
||||
the four trip exports write ISO; 474 rows parse validly under both readings.
|
||||
Guessing per row silently swaps January and February for some rows and not
|
||||
others.
|
||||
2. **A person's column is net balance impact, not their share.** The payer is
|
||||
whoever is positive; the other's share is `|their negative| / cost`. So a
|
||||
`+cost / -cost` row means the other party owes **100%** — not that the
|
||||
expense was unshared, which is the reading that fakes an arrangement change.
|
||||
3. **Matching is one-to-one, best pair first.** The NZ trip has two identical
|
||||
$10.16 Uber rows against three ledger rows; without this a ledger row is
|
||||
claimed repeatedly while the second CSV row looks matched and is not.
|
||||
|
||||
**The 44% is a coverage ceiling, not a matcher weakness.** The CSVs describe 678
|
||||
shared expenses in 2024; the ledger holds 591 rows for all of 2024, 3 to 72 a
|
||||
month, far less than a household actually spends. South Korea April 2024 matches
|
||||
4 of 158. Chasing a higher rate is chasing transactions that were never
|
||||
imported.
|
||||
|
||||
### A reversed recommendation
|
||||
|
||||
The 2026-07-26 proposal said, under *What I would not do*: "**Do not** restate
|
||||
history from the SplitMyExpenses CSVs… the value is low: those balances are
|
||||
settled and will not change."
|
||||
|
||||
That was overturned on 2026-07-28, and it was wrong in an instructive way: it
|
||||
measured the value in *balances*, where it is indeed nil, and missed the value
|
||||
in *spend*, where it is $35,259. Importing as `settled` gets the second without
|
||||
touching the first. The "combining problem" it cited is real and is why the
|
||||
match rate is capped — but a partial restatement of spend beats none, and rows
|
||||
that cannot be matched simply keep their current treatment.
|
||||
|
||||
## What was rejected
|
||||
|
||||
**`settlement_contexts` as a table.** The need was real — a payment must say
|
||||
what it settles. But trips already exist and already carry membership on
|
||||
`transaction_overrides.trip_id`, so scope is a read of existing data rather
|
||||
than a new grouping key. One nullable column on `split_payments` expressed it.
|
||||
|
||||
A general context table would have meant a new entity to create and maintain
|
||||
before a payment could be recorded, in a two-person household with two trips.
|
||||
|
||||
**Deleting duplicate transactions.** Every child of `transactions` is
|
||||
`ON DELETE CASCADE`, and which member of a duplicate pair holds the curation is
|
||||
an accident of import order. Duplicates are superseded instead: the row stays,
|
||||
keeps its children, and points at the row that replaces it.
|
||||
|
||||
**Reusing `reconciled_with_id` for duplicates.** Its predicate is scoped to
|
||||
`statement_id IS NULL` on purpose — a statement line pointing at something else
|
||||
is the survivor, not the duplicate. In the duplicate-import case both rows are
|
||||
statement lines, so that predicate can never hide either.
|
||||
|
||||
## Scale note
|
||||
|
||||
This is a home app for one user, occasionally two, and the second user consumes
|
||||
the splits view and little else. Reviews of this subsystem have repeatedly
|
||||
proposed enterprise-grade reconciliation, lineage and audit machinery; the
|
||||
*findings* are often right and the *sizing* is not. A one-column solution a
|
||||
person can hold in their head beats a correct-but-unmaintainable one here.
|
||||
|
||||
---
|
||||
|
||||
## Still a proposal: the shared loan
|
||||
|
||||
**Nothing in this section is built.**
|
||||
|
||||
Sonu's contributions are in the ledger and unrecognised. All are categorised
|
||||
`transfers` — correct for spend, but it makes a loan contribution and an expense
|
||||
settlement indistinguishable:
|
||||
|
||||
| Pattern in offset credits | Rows | Total | Meaning |
|
||||
|---|---:|---:|---|
|
||||
| `…emi` | 39 | $37,980.24 | Sonu's loan contribution |
|
||||
| `…mummy…` | 6 | $29,721.24 | Molina's money, forwarded by Sonu |
|
||||
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
|
||||
|
||||
### The loan is separate from shared expenses
|
||||
|
||||
Different obligations, different rhythms, different nature: one funds an asset,
|
||||
the other funds consumption. They do not share a settlement scope, and a
|
||||
contribution must never settle a dinner.
|
||||
|
||||
### The share is 50/50 fixed, with the shortfall tracked
|
||||
|
||||
Not derived from actual payments, which fluctuate. Over 2025-07-01 → 2026-06-30:
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Repayments | $63,500.00 |
|
||||
| Sonu's 50% obligation | $31,750.00 |
|
||||
| Actually contributed (26 payments) | $27,750.00 |
|
||||
| **Shortfall** | **$4,000.00** |
|
||||
|
||||
She never missed a fortnight; the rate changed — $1,250 × 15 (Aug 2025–Feb
|
||||
2026, the correct 50%), $1,000 × 3 (Jul 2025, pre-adjustment), $750 × 8
|
||||
(Mar–Jun 2026, leave).
|
||||
|
||||
So the model needs a **contribution schedule** (expected per period) alongside
|
||||
actual contributions, with the running difference as a tracked receivable. A
|
||||
flat percentage-of-actual cannot express "obligation unchanged, payment
|
||||
temporarily reduced, difference owed" — it would silently redefine her share as
|
||||
30% and make the shortfall disappear.
|
||||
|
||||
### Interest: recommended as expense, pending final call
|
||||
|
||||
Over 12 months $63,500 of cash left and debt fell by $44,127.36. The $16,523.64
|
||||
difference bought nothing and is not recoverable — an expense by definition.
|
||||
Excluding it leaves the balance sheet unable to reconcile cash out against
|
||||
equity gained, and understates annual cost by ~10%.
|
||||
|
||||
The legitimate concern is that interest is non-discretionary. The answer is a
|
||||
fixed-commitments grouping alongside rent, insurance and utilities — a
|
||||
presentation change, not an exclusion.
|
||||
|
||||
**Do not** model the loan as a recurring split: that would put $2,500 a
|
||||
fortnight of principal into spend, the error migration 0014 exists to prevent.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does equity need tracking per person?** If Sonu accrues a share of the
|
||||
principal, that is a balance-sheet item the app has no concept of. Probably
|
||||
belongs in a net-worth view rather than here.
|
||||
2. **Attribution of forwarded payments.** `mummy` in the description reliably
|
||||
marks Molina's money in all six known cases, but it is a description match on
|
||||
a free-text field. Acceptable as a *suggestion* requiring confirmation, not
|
||||
as an automatic rule.
|
||||
3. **The solo leg.** A Qantas booking on 23 Apr (txn 2849, $1,366.40) is the
|
||||
flight to Bangkok that begins a solo leg, and the Singapore spending
|
||||
($2,242.05, 40 rows, to 9 May) is solo — not shared. It has no trip record.
|
||||
Worth one if trip *cost* is wanted for it; nothing about sharing depends on
|
||||
it.
|
||||
|
||||
## Resolved
|
||||
|
||||
- **Europe — Sonu + Sunny** (trip 3, 2026-04-12 → 2026-04-28, 124 rows,
|
||||
$9,914.24). The leg after the group trip, previously marked only by tag 5 and
|
||||
invisible to trip analytics. Includes one advance booking on 17 Mar
|
||||
(Ticketmaster Nanterre) and the 12 Apr handover-day rows, which were already
|
||||
held out of Europe 2026.
|
||||
|
||||
- **Grouped payments, split by scope.** Payments are made grouped — one transfer
|
||||
covers several tabs — and that needs no schema change, because
|
||||
`split_payments` has no unique constraint on `linked_transaction_id`. So one
|
||||
bank transfer carries one row per scope, and the rows re-add to the transfer.
|
||||
|
||||
Sonu's two "transfer" payments were allocated Europe-first, remainder to
|
||||
household, chronologically so each settles what was outstanding when it was
|
||||
made:
|
||||
|
||||
| Transfer | Scope | Amount |
|
||||
|---|---|---:|
|
||||
| $3,779.33, 12 Apr (txn 4111) | Europe 2026 | 1,084.61 |
|
||||
| | Europe — Sonu + Sunny | 2,694.72 |
|
||||
| $4,794.06, 16 May (txn 4121) | Europe — Sonu + Sunny | 1,145.52 |
|
||||
| | household | 3,648.54 |
|
||||
|
||||
Both Europe tabs now read $0.00 and her overall balance is unchanged at
|
||||
$5,428.08 — allocation moves money between tabs, never between people. That
|
||||
invariance is the check worth repeating on any future re-allocation.
|
||||
@@ -1,752 +0,0 @@
|
||||
# UI and information architecture review
|
||||
|
||||
**Date:** 2026-07-26
|
||||
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 1–4 remain
|
||||
proposals, tracked on the board under epic **Analytics & Insights information
|
||||
architecture** (#154) — verified 2026-08-02, nothing in 1, 2 or 4 has been
|
||||
started, and 3 is partially covered by the shared-expenses rebuild rather than by
|
||||
this review.
|
||||
|
||||
## Implementation status — Priority 0 (2026-07-27)
|
||||
|
||||
All six Priority 0 items landed, with three amendments found while verifying the
|
||||
proposals against the code:
|
||||
|
||||
1. **Reconciled source rows** — the exclusion was missing from *all five*
|
||||
analytics routes, not only `/monthly`. It is now one fragment
|
||||
(`EXCLUDE_RECONCILED_SOURCE`) that `queries.ts` also imports, so the two
|
||||
halves cannot drift apart again. Real effect: 48 rows, **$4,474.79** of
|
||||
double-counted spend removed from every category total, mover, Pareto and
|
||||
merchant ranking.
|
||||
2. **Spend pace** — now served by `/api/analytics/daily`, built from the same
|
||||
fragments as the headline. Measured on live data, the old client-side series
|
||||
ended July at **$4,747.31** against a headline of **$3,597.10** — a 32%
|
||||
overstatement of the number directly above it.
|
||||
3. **Fees and interest** — bounded by an explicit period (default 12 months,
|
||||
`months=0` for all time), with the range shown and selectable. The unbounded
|
||||
figure was overstating the last 12 months by roughly **$2,700 of fees**.
|
||||
4. **Split-coverage warning** — *deliberately not implemented* (user decision,
|
||||
2026-07-27).
|
||||
5. **Shared foreign currency** — amended. The obvious fix, reading `s.currency`,
|
||||
would have mislabelled every order row as AUD, because an order receipt has
|
||||
no statement and carries its own currency. Sourcing is now
|
||||
`NATIVE_CURRENCY = COALESCE(s.currency, t.foreign_currency_code, 'AUD')`,
|
||||
whose COALESCE order keeps two opposite denomination conventions apart. Note
|
||||
this change is **latent on today's data**: no foreign transaction is
|
||||
currently split, so nothing on Shared looks different yet.
|
||||
6. **Partial-month comparisons** — the hero average, the top movers and the pace
|
||||
baseline now exclude the in-progress month, and compare through the same day
|
||||
of the month when the selected month is the current one.
|
||||
|
||||
Also fixed while in here, both found by checking rather than by proposal:
|
||||
|
||||
- **Every analytics window was a day early.** `toISOString()` on a local-midnight
|
||||
`Date` converts backwards through UTC in any timezone east of Greenwich. Now
|
||||
`toDateStr()`. This was pre-existing in `/monthly` and `/merchants`.
|
||||
- **Rounding grain.** `/monthly` rounded per category and `/daily` per
|
||||
category-day, so the pace chart ended the month a few cents off its own
|
||||
headline. Both now carry 4dp and round once, at display.
|
||||
|
||||
Guarded in two files. `src/__tests__/integration/analytics-sql.test.ts` covers
|
||||
the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and
|
||||
`INVESTMENT_SIGNED`. Since 2026-08-21 `analytics-priority0.test.ts` covers items
|
||||
2, 3 and 6 (#155): the three where a regression is silent rather than loud — the
|
||||
pace chart simply going back to disagreeing with the headline printed directly
|
||||
above it, the fees figure back to growing forever, and a partial month back to
|
||||
being measured against complete ones, none of which fails anything.
|
||||
|
||||
Two things were needed to make item 6 testable at all, and both are the point
|
||||
rather than incidental. The rules lived as closures inside `budget/page.tsx`
|
||||
reading `new Date()` inline, so they now live in `src/lib/partial-month.ts` with
|
||||
an injectable clock; and the tests freeze that clock, because a fixture defined
|
||||
against the real "now" drifts out of the routes' rolling windows and the
|
||||
assertion goes vacuous — the same class of silent failure, one level up. Item
|
||||
2's suite carries a placebo assertion for the same reason: parity between two
|
||||
queries that are identical by construction proves nothing unless some fixture
|
||||
can actually tell the old and new spend definitions apart.
|
||||
|
||||
### Landed after this doc, in the same family (2026-08-02)
|
||||
|
||||
Two changes postdate the Priority 0 pass and belong to the same
|
||||
metric-integrity thread, so read them alongside it:
|
||||
|
||||
- **The investments line is signed** (`c70d2b1`, 2026-07-31). `INVESTMENT_SIGNED`
|
||||
makes credits and refunds negative so a withdrawal nets against contributions
|
||||
instead of reading as more money invested. March 2026 had shown $38,615.34 of
|
||||
investing in a month that was net **−$11,384.66**. Consequence for the
|
||||
Analytics section below: the "income, expenses, invested, and net-cash strip"
|
||||
it describes now carries a *signed* invested figure, and a net-disinvesting
|
||||
month is real data — the budget page gates on `!== 0`, not `> 0`.
|
||||
- **Transfers hidden by default in the transactions view** (`f6c500b`,
|
||||
2026-07-30), with an explicit category pick overriding the exclusion and the
|
||||
default off when scoped to a statement. An IA change in this review's
|
||||
territory that this review did not propose.
|
||||
|
||||
The doc's characterisation of `REGULAR_CATEGORIES` (Insights section) is also
|
||||
slightly off: the set has 13 members including rent, utilities, insurance and
|
||||
subscriptions, not the 8 listed. The case for replacing it stands — a flat
|
||||
binary cannot express obligation — but that is the reason, not arbitrary
|
||||
membership. Note too that the proposed Fixed/Essential/Lifestyle model needs a
|
||||
commitment dimension that does not exist yet: `fees` cannot be split into
|
||||
avoidable versus known-annual, `subscriptions` cannot be split into contractual
|
||||
versus cancellable, and the contracted loan repayment is not in the spend stream
|
||||
at all (`SPEND_BASE` keeps only the interest portion). That is a data-model
|
||||
change, not an Insights rework.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The July 19 UI refresh gave the app a cohesive and distinctive visual identity.
|
||||
The ink-and-copper palette, typography, financial number treatment, month spine,
|
||||
and transaction drill-downs are all strong foundations.
|
||||
|
||||
The larger remaining issue is not appearance. It is information hierarchy.
|
||||
Analytics and Insights contain useful data, but they are reporting-heavy rather
|
||||
than decision-oriented. Shared communicates the immediate running balance, but
|
||||
the current settlement model prevents it from answering which expenses a payment
|
||||
settled, whether a trip is closed, or how the shared loan should be represented.
|
||||
|
||||
The product should make four questions easy to answer:
|
||||
|
||||
1. Am I financially okay?
|
||||
2. What changed and why?
|
||||
3. What needs my attention?
|
||||
4. Who owes what, and for which expenses?
|
||||
|
||||
Today there is no single page that answers the first three. The app opens on
|
||||
Transactions and presents ten equally weighted navigation items.
|
||||
|
||||
The recommended direction is:
|
||||
|
||||
- Add an Overview as the default landing page.
|
||||
- Keep Analytics focused on historical exploration: **what happened?**
|
||||
- Rebuild Insights around decisions and attention: **what should I know or do?**
|
||||
- Rebuild Shared around settlement contexts: **who owes what, and why?**
|
||||
- Keep the shared loan as a separate ledger from shared consumption expenses.
|
||||
- Fix calculation and coverage inconsistencies before adding more visualisations.
|
||||
|
||||
## Context reviewed
|
||||
|
||||
This review covered:
|
||||
|
||||
- The current Next.js pages and shared components.
|
||||
- Analytics SQL and API calculations.
|
||||
- Shared-expense balance and transaction queries.
|
||||
- `CLAUDE.md`.
|
||||
- `docs/shared-expenses-design.md`.
|
||||
- `docs/expense-baseline.md`.
|
||||
- Recent repository history.
|
||||
- Recent finance-app memories retrieved from OpenViking.
|
||||
|
||||
The OpenViking history confirmed:
|
||||
|
||||
- The July 19 redesign intentionally introduced the ink-and-copper theme,
|
||||
Fraunces display type, month-spine navigation, top movers, category
|
||||
sparklines, and heat-tinted ledger tables.
|
||||
- The user prefers a modern, high-fidelity interface and actionable analytics.
|
||||
- Later July 25–26 work changed the financial meaning under those screens:
|
||||
split-aware personal spend, AUD-aware settlement, refund netting, loan
|
||||
principal/interest separation, rule previews, and the proposed contextual
|
||||
settlement model.
|
||||
- The preferred settlement model links payments to real transactions, separates
|
||||
Household, Trip, and Historical contexts, and keeps the shared loan separate.
|
||||
|
||||
## What already works
|
||||
|
||||
### Visual system
|
||||
|
||||
- The dark ink-and-copper theme is coherent and distinctive.
|
||||
- Serif headings and mono financial figures create useful hierarchy.
|
||||
- The copper accent is used consistently for selection and emphasis.
|
||||
- The design feels like one application rather than a collection of unrelated
|
||||
pages.
|
||||
|
||||
### Analytics interactions
|
||||
|
||||
- The month spine is an effective year-at-a-glance navigation control.
|
||||
- “What changed” is more useful than a generic category chart.
|
||||
- Category sparklines make direction visible without creating a large
|
||||
multi-series chart.
|
||||
- Category rows can be expanded into their transactions.
|
||||
- Inline recategorisation allows users to correct the data while investigating
|
||||
it.
|
||||
|
||||
### Shared workflow
|
||||
|
||||
- “Owes you,” “you owe,” and “all square” communicate the immediate relationship
|
||||
balance clearly.
|
||||
- Payment history is preserved rather than reducing settlement to a boolean.
|
||||
- Participant and tag filters support practical investigation.
|
||||
- Split transactions can be edited without returning to the main transaction
|
||||
page.
|
||||
|
||||
## App-wide information architecture
|
||||
|
||||
### Current problem
|
||||
|
||||
The app redirects `/` to `/transactions`. This makes the operational ledger the
|
||||
default product surface. Transactions are important, but they do not tell the
|
||||
user whether anything needs attention or what the current financial position
|
||||
means.
|
||||
|
||||
The sidebar also gives equal weight to:
|
||||
|
||||
- operational screens such as Reconcile;
|
||||
- analytical screens such as Analytics;
|
||||
- configuration screens such as Rules;
|
||||
- organisational screens such as Tags.
|
||||
|
||||
This makes the product feel like a database administration interface even when
|
||||
the individual pages are well designed.
|
||||
|
||||
The `/budget` route is labelled Analytics in navigation. This is a leftover from
|
||||
an older product concept and should become `/analytics`.
|
||||
|
||||
### Recommended navigation
|
||||
|
||||
Group navigation by intent:
|
||||
|
||||
**Overview**
|
||||
|
||||
- Overview
|
||||
|
||||
**Money**
|
||||
|
||||
- Transactions
|
||||
- Statements
|
||||
- Reconcile
|
||||
|
||||
**Understand**
|
||||
|
||||
- Analytics
|
||||
- Insights
|
||||
- Merchants
|
||||
|
||||
**Shared**
|
||||
|
||||
- Shared
|
||||
- Trips
|
||||
- Loan
|
||||
|
||||
**Organise**
|
||||
|
||||
- Tags
|
||||
- Rules
|
||||
|
||||
Lower-frequency configuration items can be visually separated or collapsed.
|
||||
|
||||
### Recommended Overview
|
||||
|
||||
The default landing page should be a concise status and attention surface, not
|
||||
another full analytics dashboard.
|
||||
|
||||
Suggested structure:
|
||||
|
||||
1. **This month**
|
||||
- Personal spend to date
|
||||
- Expected baseline at this point in the month
|
||||
- Income
|
||||
- Net cash
|
||||
|
||||
2. **Financial resilience**
|
||||
- Realistic monthly baseline
|
||||
- Cash coverage in months
|
||||
- Redraw shown separately from cash
|
||||
|
||||
3. **Needs attention**
|
||||
- Uncategorised or `other` transactions
|
||||
- Unreconciled transactions
|
||||
- Statements failing balance assertions
|
||||
- New or unusual recurring charges
|
||||
- Shared expenses added since the last settlement
|
||||
|
||||
4. **Shared**
|
||||
- Current balances by person and context
|
||||
- Loan contribution shortfall shown separately
|
||||
|
||||
5. **Recent change**
|
||||
- The two or three categories that explain the largest movement
|
||||
|
||||
The Overview should link into Analytics, Insights, Shared, and Reconcile rather
|
||||
than reproduce their complete tables.
|
||||
|
||||
## Analytics review
|
||||
|
||||
### What the current page does
|
||||
|
||||
The current Analytics page includes:
|
||||
|
||||
- selected-month spend hero;
|
||||
- twelve-month month spine;
|
||||
- income, expenses, invested, and net-cash strip;
|
||||
- top category movers;
|
||||
- eight category sparkline cards;
|
||||
- spend-concentration Pareto chart;
|
||||
- cumulative spend pace;
|
||||
- expandable category table;
|
||||
- six-month heat-tinted category ledger.
|
||||
|
||||
Each component is defensible in isolation. Together, they create too many
|
||||
competing summaries of the same category data.
|
||||
|
||||
### What Analytics should answer
|
||||
|
||||
Analytics should answer:
|
||||
|
||||
> What happened during this period, how does it compare, and what explains the
|
||||
> difference?
|
||||
|
||||
Recommended primary structure:
|
||||
|
||||
1. Period and comparison controls.
|
||||
2. Personal spend, income, invested, and net cash.
|
||||
3. Explanation of the change versus the selected comparison.
|
||||
4. One main category/trend visualisation.
|
||||
5. Category breakdown with transaction drill-down.
|
||||
6. An optional Explore section for detailed tables.
|
||||
|
||||
### Recommended removals and consolidation
|
||||
|
||||
- Keep either category sparklines or the six-month ledger as the primary
|
||||
category-trend representation, not both.
|
||||
- Move the Pareto chart behind an Explore section. It describes concentration
|
||||
but rarely produces an immediate decision.
|
||||
- Retain “What changed,” but make each item clickable and explain which
|
||||
transactions caused the movement.
|
||||
- Avoid comparing a partial current month with full prior months unless values
|
||||
are projected or compared through the same day.
|
||||
- Add gross-versus-personal-share switching only if it is clearly labelled.
|
||||
Personal share should remain the default.
|
||||
|
||||
### Calculation and trust issues
|
||||
|
||||
#### Reconciled source rows can be double-counted
|
||||
|
||||
`/api/analytics/monthly` does not currently exclude manual source rows where
|
||||
`reconciled_with_id IS NOT NULL`. The baseline analysis identified 48
|
||||
double-counted rows.
|
||||
|
||||
The analytics query should apply the same reconciled-row exclusion used by the
|
||||
main transaction queries.
|
||||
|
||||
#### Spend pace compares unlike numbers
|
||||
|
||||
The Analytics headline uses:
|
||||
|
||||
- split-adjusted personal share;
|
||||
- fees and interest;
|
||||
- refund and credit netting;
|
||||
- loan interest rather than principal;
|
||||
- non-spend-category exclusions.
|
||||
|
||||
The cumulative spend-pace chart uses only `transaction_type === "debit"` and
|
||||
adds gross `amount_aud ?? amount`. It does not use personal share and does not
|
||||
apply the same refund, fee, interest, or loan semantics.
|
||||
|
||||
The chart can therefore disagree with the headline while both appear to
|
||||
represent “spend.” The cumulative series should be produced by the same
|
||||
server-side spend semantics as the monthly total.
|
||||
|
||||
#### Split coverage changes mid-series
|
||||
|
||||
Reliable in-app split data begins on 2026-01-09. A trailing twelve-month personal
|
||||
series currently combines older gross spending with newer split-adjusted
|
||||
spending.
|
||||
|
||||
Until historical splits are restored:
|
||||
|
||||
- default personal trend analysis to February–June 2026;
|
||||
- visibly mark periods with incomplete split coverage; or
|
||||
- offer gross-only twelve-month comparison separately.
|
||||
|
||||
Do not present the mixed series as one comparable personal-spend trend.
|
||||
|
||||
#### Comparison baseline is too naive
|
||||
|
||||
The selected month is compared against the average of all other months with
|
||||
data. That average can include travel, annual fees, tax payments, incomplete
|
||||
current periods, and months with incompatible split coverage.
|
||||
|
||||
Better comparison choices:
|
||||
|
||||
- previous month;
|
||||
- same month last year;
|
||||
- median of comparable complete months;
|
||||
- recurring baseline;
|
||||
- user-selected comparison.
|
||||
|
||||
### Data trust indicator
|
||||
|
||||
Analytics should include a compact methodology and coverage indicator:
|
||||
|
||||
> Personal share · refunds netted · investments excluded · split coverage
|
||||
> reliable from Feb 2026 · 12 transactions need classification
|
||||
|
||||
This makes the meaning of the numbers inspectable without overwhelming the page.
|
||||
|
||||
## Insights review
|
||||
|
||||
### Current problem
|
||||
|
||||
The current Insights page contains:
|
||||
|
||||
- Regular versus occasional spending;
|
||||
- another monthly category breakdown;
|
||||
- recurring charges;
|
||||
- fees and interest.
|
||||
|
||||
The monthly breakdown duplicates Analytics. The page does not yet surface the
|
||||
most decision-relevant findings already known from the data: sustainable monthly
|
||||
cost, liquidity, the loan-overpayment lever, data-quality weaknesses, or unusual
|
||||
changes requiring attention.
|
||||
|
||||
### “Regular” is not the same as committed or essential
|
||||
|
||||
`REGULAR_CATEGORIES` includes:
|
||||
|
||||
- groceries;
|
||||
- dining;
|
||||
- transport;
|
||||
- health;
|
||||
- personal care;
|
||||
- government;
|
||||
- charity;
|
||||
- pets.
|
||||
|
||||
These may recur, but they have very different flexibility and obligation.
|
||||
“Regular” describes transaction behaviour, not financial necessity.
|
||||
|
||||
The current chart therefore cannot answer:
|
||||
|
||||
- What is the minimum monthly cost?
|
||||
- What can be cut?
|
||||
- What is contractually committed?
|
||||
- What is lifestyle spending?
|
||||
- What is a one-off?
|
||||
|
||||
### Recommended model
|
||||
|
||||
Replace Regular versus Occasional with:
|
||||
|
||||
1. **Fixed commitments**
|
||||
- Contracted loan repayment
|
||||
- Insurance
|
||||
- Rates and registration
|
||||
- Known annual fees
|
||||
- Contractual subscriptions
|
||||
|
||||
2. **Essential variable spending**
|
||||
- Utilities
|
||||
- Groceries
|
||||
- Transport
|
||||
- Health
|
||||
|
||||
3. **Lifestyle and discretionary**
|
||||
- Dining
|
||||
- Shopping
|
||||
- Entertainment
|
||||
- Personal care
|
||||
|
||||
4. **One-offs and travel**
|
||||
|
||||
5. **Investments and transfers**
|
||||
- Shown for cashflow context, excluded from spending
|
||||
|
||||
This should support scenario views rather than claiming there is one true
|
||||
baseline.
|
||||
|
||||
### Recommended Insights structure
|
||||
|
||||
#### 1. Financial baseline
|
||||
|
||||
Show the scenarios already established by the expense-baseline analysis:
|
||||
|
||||
- Survival: contracted loan repayment and essentials only.
|
||||
- Realistic: contracted loan repayment plus ordinary dining and charity.
|
||||
- Status quo: current loan overpayment and normal life excluding travel.
|
||||
|
||||
For each scenario show:
|
||||
|
||||
- monthly amount;
|
||||
- six-month reserve;
|
||||
- twelve-month reserve.
|
||||
|
||||
#### 2. Liquidity and resilience
|
||||
|
||||
Show:
|
||||
|
||||
- cash available;
|
||||
- redraw available separately;
|
||||
- months covered under each baseline;
|
||||
- a warning that redraw is lender-controlled and not equivalent to cash.
|
||||
|
||||
#### 3. Biggest flexible levers
|
||||
|
||||
Examples:
|
||||
|
||||
- voluntary loan overpayment;
|
||||
- dining;
|
||||
- shopping;
|
||||
- subscriptions;
|
||||
- travel.
|
||||
|
||||
The loan should always show both the contracted floor and actual repayment.
|
||||
|
||||
#### 4. Attention and anomalies
|
||||
|
||||
Examples:
|
||||
|
||||
- a new recurring charge;
|
||||
- a charge larger than its prior range;
|
||||
- a category materially above baseline;
|
||||
- a fee increase;
|
||||
- an unexpected incoming credit categorised as spend;
|
||||
- a merchant still classified as `other`;
|
||||
- an investment incorrectly counted as spending.
|
||||
|
||||
Each insight should link directly to the affected transactions.
|
||||
|
||||
#### 5. Data-quality work queue
|
||||
|
||||
The baseline analysis found that data quality is currently a larger blocker than
|
||||
visualisation:
|
||||
|
||||
- `other` remains a large unresolved category;
|
||||
- Raiz, Vanguard Super, and moomoo need investment classification;
|
||||
- incoming `other` credits can make spending negative;
|
||||
- `government` conflates tax with rates and registration;
|
||||
- annual fees distort short-window monthly averages.
|
||||
|
||||
Insights should make these visible as fixable tasks.
|
||||
|
||||
### Recurring charges
|
||||
|
||||
The current detector identifies merchants with regular transaction intervals.
|
||||
That does not necessarily mean a subscription or commitment. Weekly grocery
|
||||
shopping can look recurring.
|
||||
|
||||
Recommended changes:
|
||||
|
||||
- Rename the section **Recurring patterns** unless contractual charges can be
|
||||
distinguished.
|
||||
- Show confidence and the basis for classification.
|
||||
- Show the next expected charge date.
|
||||
- Separate likely subscriptions from recurring merchants.
|
||||
- Allow dismissing or confirming a detected pattern.
|
||||
- Highlight price changes.
|
||||
- Collapse inactive patterns by default.
|
||||
|
||||
The current eight-column table is also too wide for a primary page. Put secondary
|
||||
fields such as first seen, total paid, and count into an expandable detail row.
|
||||
|
||||
### Fees and interest
|
||||
|
||||
The current fees query aggregates statement summary values across all available
|
||||
statements without a date filter. The UI does not label the period, so the total
|
||||
looks like a current-period figure even though it is effectively lifetime to
|
||||
date.
|
||||
|
||||
Recommended presentation:
|
||||
|
||||
- Explicit date range.
|
||||
- Avoidable fees.
|
||||
- Known annual fees.
|
||||
- Credit-card interest.
|
||||
- Loan interest.
|
||||
- Change versus prior comparable period.
|
||||
- Drill-down transactions.
|
||||
|
||||
Loan interest should remain spending, but appear under fixed or
|
||||
non-discretionary costs rather than being hidden.
|
||||
|
||||
## Shared review
|
||||
|
||||
### What the current page answers well
|
||||
|
||||
The unfiltered balance cards correctly implement a running ledger:
|
||||
|
||||
> splits minus payments
|
||||
|
||||
*(Superseded 2026-07-28: the shared-expenses rebuild made `settled`
|
||||
load-bearing — it gates `ACTIVE_OBLIGATION` and survives split rewrites. See
|
||||
`docs/shared-expenses-design.md` for the live model; the paragraph above
|
||||
described the pre-rebuild state.)*
|
||||
|
||||
### What the current model cannot answer
|
||||
|
||||
- Which split expenses did a payment settle?
|
||||
- Is a particular trip settled?
|
||||
- Can a trip be closed without closing Household?
|
||||
- Is an imported offset-account credit already represented by a manual payment?
|
||||
- What remains open inside one settlement context?
|
||||
- How should the shared-loan contribution shortfall be shown?
|
||||
|
||||
The page should not imply answers that the data model cannot support.
|
||||
|
||||
### Tag-filtered balance cards are semantically misleading
|
||||
|
||||
When a tag filter is active, participant balance queries intentionally stop
|
||||
subtracting payments because payments are not attributable to a tag. The cards
|
||||
then show raw split totals for the tag.
|
||||
|
||||
This behavior is explained in small text, but the card still says “owes you” or
|
||||
“you owe.” That looks like a real payable balance when it is not.
|
||||
|
||||
When filtered, relabel the cards:
|
||||
|
||||
> Split total in Europe 2026
|
||||
|
||||
Do not show payment or settlement actions from that state.
|
||||
|
||||
### Recommended settlement-context design
|
||||
|
||||
Use explicit settlement contexts:
|
||||
|
||||
- Household
|
||||
- Individual trips
|
||||
- Historical / Pre-2026
|
||||
- Closed contexts
|
||||
|
||||
Recommended Shared navigation:
|
||||
|
||||
- All
|
||||
- Household
|
||||
- Trips
|
||||
- Closed
|
||||
|
||||
Within a context show:
|
||||
|
||||
1. Net balance and direction.
|
||||
2. Expenses added since the last settlement.
|
||||
3. Payments attributed to that context.
|
||||
4. A chronological activity ledger combining expenses and payments.
|
||||
5. Context status: running, ready to settle, or closed.
|
||||
6. Settlement action.
|
||||
|
||||
### Payments should link to transactions
|
||||
|
||||
An offset-account credit and a manual `split_payments` row can represent the same
|
||||
money. The page should:
|
||||
|
||||
- propose matching an imported credit to a settlement;
|
||||
- display the linked transaction;
|
||||
- prevent silent duplication;
|
||||
- allow a manual payment only when no matching transaction exists.
|
||||
|
||||
“Record Payment” should become a context-aware settlement flow:
|
||||
|
||||
1. Choose what is being settled.
|
||||
2. Match an existing incoming transaction where possible.
|
||||
3. Confirm amount and residual balance.
|
||||
4. Preserve an auditable history.
|
||||
|
||||
### Shared transaction table
|
||||
|
||||
The table currently shows raw `tx.amount` with a dollar sign and no currency
|
||||
indicator. Participant balances correctly convert to AUD.
|
||||
|
||||
For foreign transactions, show:
|
||||
|
||||
- the native amount and currency;
|
||||
- the AUD equivalent;
|
||||
- splits based on the AUD settlement amount.
|
||||
|
||||
This prevents a visible mismatch between transaction rows and participant
|
||||
balances.
|
||||
|
||||
### Shared loan
|
||||
|
||||
The loan is not a shared-expense settlement context. It funds an asset rather
|
||||
than consumption, and a loan contribution must never settle a dinner or utility
|
||||
bill.
|
||||
|
||||
Give it a separate page or clearly separated ledger showing:
|
||||
|
||||
- expected contribution by period;
|
||||
- actual contribution;
|
||||
- running shortfall or receivable;
|
||||
- principal reduction;
|
||||
- interest expense;
|
||||
- contracted repayment;
|
||||
- actual repayment;
|
||||
- voluntary overpayment;
|
||||
- redraw movement.
|
||||
|
||||
The partner obligation is a fixed 50% of the repayment schedule, not a percentage
|
||||
inferred from actual contributions.
|
||||
|
||||
## Responsive and interaction improvements
|
||||
|
||||
- Replace wide eight-column primary tables with compact rows and expandable
|
||||
details.
|
||||
- Keep financial summaries readable at mobile widths without horizontal
|
||||
scrolling.
|
||||
- Add explicit loading skeletons rather than only text.
|
||||
- Add error states for failed analytics requests.
|
||||
- Ensure chart meaning is not conveyed by colour alone.
|
||||
- Give interactive chart regions keyboard-accessible equivalents.
|
||||
- Confirm material deletions, including payment-history deletion.
|
||||
- Make expandable table rows use buttons with appropriate accessibility state.
|
||||
- Use consistent labels for personal share, gross amount, native currency, and
|
||||
AUD equivalent.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
### Priority 0 — metric integrity *(done 2026-07-27; test gap #155)*
|
||||
|
||||
1. Exclude reconciled source rows from monthly analytics.
|
||||
2. Make spend pace use the same spend semantics as the headline.
|
||||
3. Add date ranges to fees and interest.
|
||||
4. Add split-coverage warnings to historical personal-share analysis.
|
||||
5. Fix Shared foreign-currency presentation.
|
||||
6. Avoid partial-month versus full-month comparisons.
|
||||
|
||||
### Priority 1 — product hierarchy *(#156)*
|
||||
|
||||
1. Add Overview and make it the default route.
|
||||
2. Group sidebar navigation by user intent.
|
||||
3. Rename `/budget` to `/analytics`.
|
||||
4. Add consistent methodology and coverage indicators.
|
||||
|
||||
### Priority 2 — Analytics and Insights *(#157)*
|
||||
|
||||
1. Simplify Analytics around period, comparison, change explanation, trend, and
|
||||
drill-down.
|
||||
2. Remove the duplicate monthly breakdown from Insights.
|
||||
3. Add baseline scenarios and liquidity coverage.
|
||||
4. Add flexible-spending levers, anomalies, and a data-quality work queue.
|
||||
5. Rework recurring patterns and fees into decision-oriented summaries.
|
||||
|
||||
### Priority 3 — Shared *(#158; items 2 and 5 landed with the shared-expenses rebuild)*
|
||||
|
||||
1. Add settlement contexts.
|
||||
2. Link payments to real transactions.
|
||||
3. Add context activity ledgers and closeable trip contexts.
|
||||
4. Introduce the separate loan contribution ledger.
|
||||
5. Backfill historical closed-context splits so long-range personal analytics
|
||||
become comparable.
|
||||
|
||||
### Priority 4 — polish *(#159)*
|
||||
|
||||
1. Improve mobile layouts.
|
||||
2. Add accessibility semantics.
|
||||
3. Add richer loading, error, and empty states.
|
||||
4. Consolidate repeated card, table, filter, and page-header patterns into shared
|
||||
components.
|
||||
|
||||
## Proposed success criteria
|
||||
|
||||
The redesign is successful when:
|
||||
|
||||
- The first page explains current status and outstanding actions without opening
|
||||
multiple screens.
|
||||
- Analytics can explain why one comparable period differs from another.
|
||||
- Insights identifies baseline cost, financial resilience, flexible levers, and
|
||||
data-quality problems.
|
||||
- Every displayed total states or clearly implies its period and whether it is
|
||||
gross or personal share.
|
||||
- Historical charts do not silently combine incompatible split coverage.
|
||||
- Shared can distinguish Household, Trip, and Historical balances.
|
||||
- A settlement can be traced to both the obligation it reduces and the real
|
||||
transaction representing the payment.
|
||||
- Loan contributions cannot affect ordinary shared-expense balances.
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
# Europe 2026 — Expense Report
|
||||
|
||||
**Trip:** 22 days · Mar 21 – Apr 12, 2026
|
||||
**Route:** Rome → Venice → Ortisei (Dolomites) → Varenna (Lake Como) → St Moritz → Lucerne → Paris
|
||||
**Group:** 5 travellers — Siddharth & Meghalee (Melbourne), Molina, Sibnath & Rumna (Delhi)
|
||||
**Data:** 212 tagged transactions across all cards
|
||||
|
||||
---
|
||||
|
||||
## Group Breakdown — AU vs India
|
||||
|
||||
The two groups fly different routes and airlines. Everything else (accommodation, trains, on-trip spending) is shared across all 5 travellers.
|
||||
|
||||
| | AU Group (Siddharth + Meghalee) | India Group (Molina + Sibnath + Rumna) |
|
||||
|---|---:|---:|
|
||||
| Flights | $2,164 | $6,151 |
|
||||
| Share of shared costs | $12,577 | $18,866 |
|
||||
| **Group total** | **$14,741** | **$25,017** |
|
||||
| Per person | $7,371 | $8,339 |
|
||||
|
||||
**Shared costs (accommodation + trains + all on-trip):** $31,443
|
||||
Apportioned 2:3 by headcount — AU group (2 pax) = 40% · India group (3 pax) = 60%
|
||||
|
||||
| Scenario | Amount |
|
||||
|---|---:|
|
||||
| Total trip | $39,757 |
|
||||
| **Excl. all flights** | **$31,443** |
|
||||
| Excl. AU flights only (what India group is responsible for) | $37,593 |
|
||||
| Excl. India flights only (what AU group is responsible for) | $33,607 |
|
||||
|
||||
> Australia group flights: China Eastern MEL→PVG→FCO × 2 ($1,885) + Qatar cancellation fee ($279) = **$2,164**
|
||||
> India group flights: ITA Airways DEL→FCO × 3 (Azwebin, $2,696) + Air India CDG→DEL→GAU × 3 ($3,454) = **$6,151**
|
||||
> India group flights are ~3× the AU group total despite flying a similar number of legs — driven by 3 pax vs 2, plus the ITA outbound on top of Air India return.
|
||||
|
||||
---
|
||||
|
||||
## Overall Summary
|
||||
|
||||
| | AUD |
|
||||
|---|---:|
|
||||
| Gross charged | $42,696.73 |
|
||||
| Refunds received | −$2,939.22 |
|
||||
| **Net total spend** | **$39,757.51** |
|
||||
|
||||
| Phase | Net Spend | % of Total |
|
||||
|---|---:|---:|
|
||||
| Pre-trip bookings | $23,730.02 | 60% |
|
||||
| On-trip (22 days) | $16,027.49 | 40% |
|
||||
|
||||
**On-trip daily average:** $729/day for the group · $146/person/day
|
||||
|
||||
---
|
||||
|
||||
## Refunds & Cancellations
|
||||
|
||||
| Merchant | Charged | Refunded | Net | Detail |
|
||||
|---|---:|---:|---:|---|
|
||||
| Qatar Airways | $2,396.90 | −$2,118.10 | **$278.80** | Cancelled. Replaced with China Eastern MEL→PVG→FCO. |
|
||||
| Luxury Escapes | $3,283.80 | −$820.95 | **$2,462.85** | Partial refund on Paris Adagio Montmartre. |
|
||||
| FreeNow | $43.32 | −$0.17 | $43.15 | Hold reversal. |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Trip Bookings — $23,730 net
|
||||
|
||||
### Flights
|
||||
|
||||
| Carrier | Passengers | Gross | Refund | Net |
|
||||
|---|---|---:|---:|---:|
|
||||
| ITA Airways (via Azwebin) | Delhi group (3) DEL → FCO | $2,696.34 | — | $2,696.34 |
|
||||
| Air India | Delhi group (3) CDG → DEL → GAU (return) | $3,454.32 | — | $3,454.32 |
|
||||
| China Eastern | Siddharth MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||
| China Eastern | Meghalee MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||
| China Eastern (ancillary) | Seat/baggage fees | $187.14 | — | $187.14 |
|
||||
| Qatar Airways | Siddharth + Meghalee (cancelled) | $2,396.90 | −$2,118.10 | $278.80 |
|
||||
| **Flights total** | | **$10,432.80** | **−$2,118.10** | **$8,314.70** |
|
||||
|
||||
> ITA Airways (billed as Azwebin Fiumicino) — 3 outbound tickets Delhi → Rome plus 3 sets of ancillary fees, all charged Jan 17.
|
||||
> Qatar was booked first (Jan 17) and then cancelled; refund of $2,118.10 processed Mar 24 (during Rome stay). Net cancellation penalty: **$278.80**. China Eastern replaced it at $1,885.24 total — slightly cheaper.
|
||||
|
||||
### Accommodation
|
||||
|
||||
| Property | Destination | Nights | Booked via | Net Cost |
|
||||
|---|---|---|---|---:|
|
||||
| Fontana di Trevi area (Vrbo HA-XTP24R) | Rome | 4 | Vrbo | $2,221.66 |
|
||||
| Apartment Moro 4 — Rialto | Venice | 3 | Direct | $1,410.11 |
|
||||
| 174A Apartments Murata | Ortisei | 4 | Muse Holiday | $1,815.06 |
|
||||
| In the heart of Varenna (Vrbo HA7B9C9J) | Varenna | 2 | Vrbo | $2,274.46 |
|
||||
| Cozy Central Duplex | St Moritz | 2 | Agoda | $2,560.45 |
|
||||
| Hotel Luzernerhof | Lucerne | 3 | Agoda | $1,930.07 |
|
||||
| Adagio Paris Montmartre | Paris | 4 | Luxury Escapes | $2,745.24 |
|
||||
| **Accommodation total** | | **22 nights** | | **$14,957.05** |
|
||||
|
||||
> Paris: Luxury Escapes $3,283.80 − $820.95 refund + $282.39 extra charge = $2,745.24 net.
|
||||
> **Average cost per night (5 pax):** $680 · **per person per night:** $136
|
||||
|
||||
### Pre-booked Transport
|
||||
|
||||
| Date | Merchant | Route | Amount |
|
||||
|---|---|---|---:|
|
||||
| Jan 26 | Trenitalia (Frecciarossa Business) | Rome → Venice (5 pax) | $468.55 |
|
||||
| Mar 4 | Rail Europe | Various European routes | $372.40 |
|
||||
| | **Total** | | **$840.95** |
|
||||
|
||||
---
|
||||
|
||||
## On-Trip Spending — $16,027 net
|
||||
|
||||
### By Destination
|
||||
|
||||
| Destination | Dates | Net Spend | Txns | Daily Avg |
|
||||
|---|---|---:|---:|---:|
|
||||
| Rome *(inc. transit days Mar 19–20)* | Mar 19–24 | $2,955 | 46 | $493 |
|
||||
| Venice | Mar 25–27 | $960 | 21 | $320 |
|
||||
| Ortisei / Dolomites | Mar 28–31 | $3,595 | 19 | $899 |
|
||||
| Varenna / Lake Como | Apr 1–2 | $863 | 16 | $432 |
|
||||
| St Moritz | Apr 3–4 | $355 | 10 | $178 |
|
||||
| Lucerne | Apr 5–7 | $3,422 | 22 | $1,141 |
|
||||
| Paris | Apr 8–12 | $3,878 | 46 | $970 |
|
||||
| **Total on-trip** | **22 days** | **$16,027** | **180** | **$729** |
|
||||
|
||||
---
|
||||
|
||||
### Rome & Transit (Mar 19–24) — $2,955
|
||||
|
||||
**Mar 19–20 (transit via Shanghai → Milan → Rome):**
|
||||
- Glovo Milan × 4 deliveries: $93 — feeding the group across airport transit days
|
||||
- Shanghai Sunny restaurant: $16 (CNY 78)
|
||||
- Todis Roma groceries: $55 (manual, likely night of arrival)
|
||||
|
||||
**On ground in Rome:**
|
||||
|
||||
| Category | Key Items | Total |
|
||||
|---|---|---:|
|
||||
| Dining | Lione 21 EUR 90 ($148) · Suresh EUR 52 ($85) · Glovo × 4 ($179) · Faro Café ($40) · Bap Vaschette ($29) · various | ~$780 |
|
||||
| Transport | FreeNow × 2 ($43) · Uber × 6 ($151) · ATAC bus ($2) · Radiotaxi ($11) | ~$210 |
|
||||
| Travel | Trenitalia local ($23) · Roma Termini station tickets EUR 300 ($493) · Trenitalia EUR 275 ($452) · Trenitalia EUR 165 ($274) | ~$1,242 |
|
||||
| Entertainment | St Peter's Basilica tickets: $58 | $58 |
|
||||
| Groceries | Todis × 2 ($12) · F.Lli Casali ($17) · SumUp ($12) | $41 |
|
||||
| Shopping | Scudieri International Florence EUR 51: $84 | $84 |
|
||||
| Cash | Mar 21 cash transactions lump sum | $337 |
|
||||
|
||||
> **Florence day trip (Mar 22):** Trenitalia tickets $452 + $274 bought at Roma Termini ($493) and online, plus Scudieri shopping EUR 51.
|
||||
> The two large Trenitalia charges on Mar 22–23 suggest additional train legs beyond Rome–Venice (possibly Florence or Naples day trip and ticket changes).
|
||||
|
||||
---
|
||||
|
||||
### Venice (Mar 25–27) — $960
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Water taxi Ferrovia → accommodation | $79 |
|
||||
| Caffe Rialto Venezia (dinner) | $109 |
|
||||
| Fresh pasta restaurant (Sum Dal) | $96 |
|
||||
| Baci Pasta | $70 |
|
||||
| Dolcemente Salato (pastries/café) | $67 |
|
||||
| Farini Pizza × 3 visits | $92 |
|
||||
| Tvm Rialto Diretti 2 — shopping EUR 125 | $209 |
|
||||
| Muse Holiday extra charge EUR 60 | $100 |
|
||||
| Uber × 2 | $48 |
|
||||
| Despar groceries | $19 |
|
||||
| Gelatoteca Suso + Bar Americano | $17 |
|
||||
| Pasticceria Ballarin + Bar Filovia × 2 | $28 |
|
||||
|
||||
> Grand Canal water taxi arrival ($79) was the planned "wow moment".
|
||||
> Tvm Rialto Diretti 2 (EUR 125) — likely Murano glass or quality Venetian craft souvenirs, the largest single on-trip discretionary purchase outside of gear/activities.
|
||||
|
||||
---
|
||||
|
||||
### Ortisei / Dolomites (Mar 28–31) — $3,595
|
||||
|
||||
The most expensive on-trip leg. Five major items account for $2,857 of it:
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Erich Perathon (gear shop, Val Gardena) | **$1,477** | EUR 880 — outdoor/ski clothing or equipment in Santa Cristina |
|
||||
| Funivie Seceda cable car | **$441** | EUR 236+27 — the centrepiece cable car to 2,519m above Ortisei |
|
||||
| Trenitalia Venice → Bolzano (5 pax) | **$528** | EUR 259.50 + EUR 55 split across two bookings |
|
||||
| Taxi Demez Gregor (into valley) | **$238** | EUR 142 — private taxi from Bolzano to Ortisei (no public option for 5 with bags) |
|
||||
| Funivia Lagazuoi, Cortina | **$222** | EUR 132.50 — Cortina d'Ampezzo day trip cable car |
|
||||
| Despar Dolomiti (groceries) | **$201** | EUR 97.59 + EUR 22.30 across two shops |
|
||||
| Ristorante Meuble Pont, Cortina | **$180** | EUR 107.50 — group dinner on Cortina day trip |
|
||||
| Turonda Pizza Ortisei | $93 | |
|
||||
| Istanbul Kebab + Buffet Bolzano × 2 | $111 | Transit + local meals |
|
||||
| Lago di Braies chocolate stop | $21 | |
|
||||
| Café Adler + Caffe Corso | $34 | |
|
||||
|
||||
---
|
||||
|
||||
### Varenna / Lake Como (Apr 1–2) — $863
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Taxi Demez Gregor (out of valley) | $239 |
|
||||
| Trenitalia (onward journey) | $101 |
|
||||
| Pizza e Sfizi × 2 dinners | $113 |
|
||||
| Mr Panino Bellagio (lunch) | $67 |
|
||||
| Bernasconi Claudia Varenna | $55 |
|
||||
| Varenna 1 shop EUR 55 | $92 |
|
||||
| Valli Mara Bellagio souvenirs | $71 |
|
||||
| Bistro di Corti × 2 | $50 |
|
||||
| Lake Bakery × 2 | $30 |
|
||||
| Ristorante Liberty, Tirano | $44 |
|
||||
| Groceries (Despar, Macelleria × 2) | $49 |
|
||||
| Gelato + bakery | $28 |
|
||||
|
||||
> Bellagio day trip from Varenna was a highlight — lunch at Mr Panino, ice cream at Gilardoni Stefania, souvenirs.
|
||||
> Taxi Demez again for the exit from Val Gardena (charged Apr 1, same driver/company as arrival).
|
||||
|
||||
---
|
||||
|
||||
### St Moritz (Apr 3–4) — $355
|
||||
|
||||
Almost entirely self-catered:
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Coop supermarket (big shop) | $114 |
|
||||
| Pizzaway × 2 orders | $89 |
|
||||
| Bäckerei Bad × 3 visits | $58 |
|
||||
| Coop (additional) | $14 |
|
||||
| Bus und Service | $22 |
|
||||
| Taxi St Moritz CHF 30 | $55 |
|
||||
| SBB WC | $3 |
|
||||
|
||||
> Despite being the highest accommodation cost per night ($1,280/night), almost nothing was spent on-trip. The group self-catered using Coop and relied on the bakery.
|
||||
|
||||
---
|
||||
|
||||
### Lucerne (Apr 5–7) — $3,422
|
||||
|
||||
Three large purchases dominate:
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Rail Europe (last-minute) | **$1,526** | EUR 911.95 — Swiss Travel Pass or multi-city rail tickets; bought while already in Lucerne |
|
||||
| Daytrip.com private tour | **$1,078** | EUR 642 — private guided day tour (description: "Prague" but likely Rhine Falls or day excursion from Lucerne) |
|
||||
| Rigi Bahnen (cog railway) | **$844** | CHF 461.60 — historic Rigi mountain railway, 5 pax round trip (charged Apr 10 but relates to Lucerne stay) |
|
||||
| Hotel Rigi Kaltbad dining | $47 | Lunch at mountain hotel summit |
|
||||
| Rigi Dorfladen shop | $37 | CHF 20 at Rigi summit village |
|
||||
| Uber Eats | $94 | CHF 51.23 |
|
||||
| WAL*TENZ GMBH Zurich | $82 | CHF 45.10 — dining/café in Zurich |
|
||||
| QUZGYXOO-2 | $150 | CHF 82.50 — unidentified Lucerne charge |
|
||||
| GO4T Luzern | $114 | CHF 62.40 — likely a guided activity or boat tour |
|
||||
| Amorino Gelato + Alpineum Café | $51 | |
|
||||
| Juice Paradise bubble tea | $29 | |
|
||||
| SBB toilets × 4 | $11 | CHF 1.50 each — Swiss public toilets |
|
||||
|
||||
> The Rail Europe purchase last-minute cost $1,526 vs booking pre-trip where Swiss Travel Passes are generally cheaper. Worth pre-purchasing on future Swiss trips.
|
||||
|
||||
---
|
||||
|
||||
### Paris (Apr 8–12) — $3,878
|
||||
|
||||
The return leg via Basel with 4 nights in Paris:
|
||||
|
||||
**Transit Basel (Apr 8):**
|
||||
Bahn Mi Pho CHF 69 ($126) + McDonald's CHF 51 ($93) + Weber1s bagels ($66) + Kaffeemacher coffee ($17) + Confiseur Bachmann ($22) + Acento Argentino ($26) = **$350 in Basel** for the group during travel day
|
||||
|
||||
**Paris spending:**
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Viator/TripAdvisor guided tour | $296 | USD 203.70 — skip-the-line or guided Versailles/Paris tour |
|
||||
| Palace of Versailles tickets | $292 | Full group |
|
||||
| Louvre Museum tickets | $277 | Full group |
|
||||
| Adagio Paris hotel extra charge | $282 | EUR 169 — extra night or room upgrade |
|
||||
| FC Soleil restaurant | $207 | EUR 125 — group dinner |
|
||||
| Uber airport transfer (Apr 12) | $126 | EUR 75.98 — departure to CDG |
|
||||
| Deliveroo × 2 | $123 | EUR 52.49 + EUR 21.90 |
|
||||
| Yak and Yeti restaurant | $83 | EUR 49.90 |
|
||||
| Uber Eats | $65 | |
|
||||
| Wild Deer restaurant | $64 | EUR 38.20 |
|
||||
| Uber trips × 5 | $192 | Multiple rides across Paris |
|
||||
| Fric Frac Montmartre | $55 | EUR 33 |
|
||||
| Mitao | $56 | |
|
||||
| Navigo transit pass | $61 | Group metro/bus |
|
||||
| Little Versailles coffeeshop | $36 | |
|
||||
| Pain Pain bakery × 2 | $29 | |
|
||||
| Sarl by Orsel (groceries) × 2 | $35 | |
|
||||
| Various small | $49 | |
|
||||
|
||||
> Museums + guided tour = **$865 on Paris attractions** (Versailles, Louvre, Viator).
|
||||
> Uber was heavily used in Paris — 5+ trips totalling $254 including the CDG airport transfer.
|
||||
|
||||
---
|
||||
|
||||
## Spend by Category (Net)
|
||||
|
||||
| Category | Net Spend | Txns | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Travel | $31,290 | 51 | Flights, accommodation, trains, cable cars |
|
||||
| Dining | $4,241 | 95 | 95 restaurant/café/delivery transactions |
|
||||
| Transport | $2,636 | 35 | Uber, taxis, buses, metro passes |
|
||||
| Shopping | $2,053 | 6 | Erich Perathon $1,477 + Venice/Varenna/Florence |
|
||||
| Entertainment | $698 | 4 | Versailles, Louvre, Basilica, Bellagio souvenirs |
|
||||
| Groceries | $589 | 15 | Despar, Coop, Todis, Macelleria |
|
||||
| Other (net) | −$1,751 | 4 | Qatar refund exceeds "other" debits |
|
||||
| Uncategorised | $42 | 2 | Residual |
|
||||
| **Total** | **$39,757** | **212** | |
|
||||
|
||||
> The negative "Other" figure reflects the Qatar Airways refund ($2,118) offsetting the ITA/Azwebin ancillary fees charged in the same category.
|
||||
|
||||
---
|
||||
|
||||
## Top 15 On-Trip Transactions
|
||||
|
||||
| Date | Merchant | AUD | Category |
|
||||
|---|---|---:|---|
|
||||
| Mar 28 | Erich Perathon, Val Gardena | $1,477 | Shopping — EUR 880 gear |
|
||||
| Apr 6 | Rail Europe | $1,526 | Travel — EUR 912 rail passes |
|
||||
| Apr 7 | Daytrip.com | $1,078 | Transport — EUR 642 private tour |
|
||||
| Apr 10 | Rigi Bahnen | $844 | Travel — CHF 462 mountain railway |
|
||||
| Mar 22 | Roma Termini (train tickets) | $493 | Travel — EUR 300 |
|
||||
| Mar 22 | Trenitalia | $452 | Travel — EUR 275 |
|
||||
| Mar 28 | Trenitalia | $435 | Travel — EUR 260 |
|
||||
| Mar 30 | Funivie Seceda | $396 | Travel — EUR 236 cable car |
|
||||
| Apr 9 | Viator/TripAdvisor | $296 | Travel — USD 204 Paris tour |
|
||||
| Apr 8 | Palace of Versailles | $292 | Entertainment |
|
||||
| Apr 10 | Adagio Paris extra | $282 | Travel |
|
||||
| Apr 8 | Louvre Museum | $277 | Entertainment |
|
||||
| Mar 23 | Trenitalia | $274 | Travel — EUR 165 |
|
||||
| Mar 21 | Cash transactions (Rome) | $337 | Various |
|
||||
| Apr 1 | Taxi Demez Gregor | $239 | Transport — EUR 142 |
|
||||
|
||||
---
|
||||
|
||||
## Key Observations
|
||||
|
||||
**Flight changes cost $278.80.** Qatar was booked first (Jan 17, $2,396.90) and later cancelled; the refund of $2,118.10 came through during the Rome stay on Mar 24. China Eastern (MEL→PVG→FCO) replaced it at $1,885.24 — marginally cheaper but via Shanghai with a longer travel time.
|
||||
|
||||
**60% of spend was locked in before leaving Australia.** Pre-trip bookings (flights for all 5, accommodation across 7 cities, and pre-purchased trains) totalled $23,730 vs $16,027 spent on the ground.
|
||||
|
||||
**Dolomites was the most expensive on-trip leg** despite only 4 days — Erich Perathon EUR 880 gear purchase in Val Gardena accounts for $1,477 alone. Without it, Dolomites would be the cheapest destination per day.
|
||||
|
||||
**Rail spend ballooned in Lucerne.** Rail Europe EUR 912 ($1,526) bought last-minute in Lucerne was the single largest on-trip travel cost. Swiss Travel Passes are considerably cheaper when purchased outside Switzerland before travel.
|
||||
|
||||
**Dining: 95 transactions, $4,241 net.** The group averaged $193/day on food across 22 days ($39/person/day). Glovo delivery was the primary dining mode during transit days and in Rome (10+ orders). Paris had the most sit-down restaurant spending — FC Soleil EUR 125, Yak and Yeti EUR 50, plus multiple delivery orders.
|
||||
|
||||
**St Moritz: highest accommodation cost, lowest on-trip spend.** Agoda charged $2,560 for 2 nights but the group spent only $355 on-trip — almost entirely groceries and the local bakery.
|
||||
|
||||
**Paris Uber spend: $254 across 6+ trips** including a $126 CDG airport transfer (EUR 76). Combined with 2 Deliveroo orders and Uber Eats, rideshare/delivery accounted for ~$380 of Paris on-trip costs.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 194 KiB |
Generated
+7
-1662
File diff suppressed because it is too large
Load Diff
+3
-14
@@ -6,13 +6,7 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:setup": "bash scripts/setup-test-db.sh",
|
||||
"pretest:integration": "bash scripts/resolve-test-db.sh",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
@@ -22,9 +16,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0",
|
||||
"unpdf": "^1.8.1"
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -32,12 +24,9 @@
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.2"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO participants (name) VALUES ('Me') ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_splits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
share_percent NUMERIC(5,2) NOT NULL CHECK (share_percent > 0 AND share_percent <= 100),
|
||||
settled BOOLEAN DEFAULT FALSE,
|
||||
settled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (transaction_id, participant_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_splits_participant ON transaction_splits(participant_id);
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Add email to participants for OAuth identity mapping
|
||||
ALTER TABLE participants ADD COLUMN IF NOT EXISTS email TEXT UNIQUE;
|
||||
|
||||
-- Add owner_id and account_holder_name to statements
|
||||
ALTER TABLE statements ADD COLUMN IF NOT EXISTS owner_id INTEGER NOT NULL DEFAULT 1 REFERENCES participants(id);
|
||||
ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_holder_name TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_statements_owner_id ON statements(owner_id);
|
||||
|
||||
-- Auto-assignment mapping table: (bank_name, account_number) -> owner
|
||||
CREATE TABLE IF NOT EXISTS account_owner_mappings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bank_name TEXT NOT NULL,
|
||||
account_number TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(bank_name, account_number)
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
category TEXT NOT NULL,
|
||||
month DATE NOT NULL,
|
||||
amount_limit NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(owner_id, category, month)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_owner_month ON budgets(owner_id, month);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Add FX conversion support
|
||||
ALTER TABLE statements ADD COLUMN IF NOT EXISTS exchange_rate_to_aud NUMERIC(10,6);
|
||||
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS amount_aud NUMERIC(12,2);
|
||||
|
||||
-- Backfill: all existing data is AUD
|
||||
UPDATE transactions SET amount_aud = amount WHERE amount_aud IS NULL;
|
||||
UPDATE statements SET exchange_rate_to_aud = 1.000000 WHERE exchange_rate_to_aud IS NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2);
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE split_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_participant_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
to_participant_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
amount DECIMAL(10,2) NOT NULL CHECK (amount > 0),
|
||||
payment_date DATE NOT NULL,
|
||||
notes TEXT,
|
||||
linked_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_split_payments_from ON split_payments(from_participant_id);
|
||||
CREATE INDEX idx_split_payments_to ON split_payments(to_participant_id);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE transactions ADD COLUMN reconciled_with_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_transactions_reconciled ON transactions(reconciled_with_id) WHERE reconciled_with_id IS NOT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Trips: group transactions (via transaction_overrides.trip_id) into named
|
||||
-- trips with analytics. Catch-up migration — this DDL was applied directly to
|
||||
-- the live DB when the feature shipped; idempotent so re-running is safe.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
|
||||
archived BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE transaction_overrides
|
||||
ADD COLUMN IF NOT EXISTS trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tx_overrides_trip_id
|
||||
ON transaction_overrides (trip_id) WHERE trip_id IS NOT NULL;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Manual-only rules ("quick actions"): a rule flagged manual_only is never
|
||||
-- picked up by the bulk apply-all run. It exists to be fired by hand against a
|
||||
-- selection of transactions from the transactions page, so its conditions are
|
||||
-- irrelevant — the selection is the condition.
|
||||
|
||||
ALTER TABLE rules
|
||||
ADD COLUMN IF NOT EXISTS manual_only BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rules_manual_only
|
||||
ON rules (owner_id, manual_only) WHERE manual_only = true;
|
||||
@@ -1,99 +0,0 @@
|
||||
-- Normalise statements.statement_type to a fixed vocabulary.
|
||||
--
|
||||
-- Before this migration the column held whatever free text Gemini put in
|
||||
-- `account_type` ('Credit Card', 'credit card', 'credit_card', 'Business Card',
|
||||
-- 'ACCESS ADVANTAGE', 'multi-currency account', ...). The UI coped only by
|
||||
-- doing `.includes("card")`, which breaks as soon as bank and loan statements
|
||||
-- arrive.
|
||||
--
|
||||
-- The raw extracted text is preserved in `account_type` — this only touches
|
||||
-- `statement_type`. A BEFORE trigger normalises on write, so the N8N ingestion
|
||||
-- workflow keeps working unchanged while it still sends free text.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_statement_type(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT := lower(trim(coalesce(raw, '')));
|
||||
BEGIN
|
||||
IF v = '' THEN
|
||||
RETURN 'other';
|
||||
END IF;
|
||||
|
||||
-- Already canonical
|
||||
IF v IN ('credit_card', 'transaction', 'savings', 'loan', 'offset', 'investment', 'other') THEN
|
||||
RETURN v;
|
||||
END IF;
|
||||
|
||||
-- Offset before loan: "Mortgage Offset" is an offset account, not a loan.
|
||||
IF v LIKE '%offset%' THEN
|
||||
RETURN 'offset';
|
||||
END IF;
|
||||
|
||||
-- Loans before transaction: "home loan account" must not match '%account%'.
|
||||
IF v LIKE '%loan%' OR v LIKE '%mortgage%' THEN
|
||||
RETURN 'loan';
|
||||
END IF;
|
||||
|
||||
-- Cards: covers 'Credit Card', 'credit card', 'Business Card', 'Charge Card'
|
||||
IF v LIKE '%card%' THEN
|
||||
RETURN 'credit_card';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%saving%' OR v LIKE '%term deposit%' THEN
|
||||
RETURN 'savings';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%invest%' OR v LIKE '%share%' OR v LIKE '%broker%' THEN
|
||||
RETURN 'investment';
|
||||
END IF;
|
||||
|
||||
-- Everyday transaction accounts. Bank-specific product names go here; the
|
||||
-- generic keywords catch the rest.
|
||||
IF v LIKE '%access advantage%' -- ANZ
|
||||
OR v LIKE '%complete access%' -- Westpac
|
||||
OR v LIKE '%smart access%' -- CommBank
|
||||
OR v LIKE '%netbank%' -- CommBank
|
||||
OR v LIKE '%classic banking%' -- NAB
|
||||
OR v LIKE '%multi-currency%' -- Wise
|
||||
OR v LIKE '%multi currency%'
|
||||
OR v LIKE '%transaction%'
|
||||
OR v LIKE '%everyday%'
|
||||
OR v LIKE '%cheque%'
|
||||
OR v LIKE '%current account%'
|
||||
OR v LIKE '%debit%'
|
||||
THEN
|
||||
RETURN 'transaction';
|
||||
END IF;
|
||||
|
||||
RETURN 'other';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Normalise on write so the N8N workflow can keep sending raw account_type.
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_type_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.statement_type := normalize_statement_type(NEW.statement_type);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_type ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_type
|
||||
BEFORE INSERT OR UPDATE OF statement_type ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_type_trigger();
|
||||
|
||||
-- Backfill existing rows.
|
||||
UPDATE statements
|
||||
SET statement_type = normalize_statement_type(statement_type)
|
||||
WHERE statement_type IS DISTINCT FROM normalize_statement_type(statement_type);
|
||||
|
||||
-- Guard the vocabulary. Safe because the trigger runs first on every write.
|
||||
ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_statement_type_check;
|
||||
ALTER TABLE statements
|
||||
ADD CONSTRAINT statements_statement_type_check
|
||||
CHECK (statement_type IN ('credit_card', 'transaction', 'savings', 'loan', 'offset', 'investment', 'other'));
|
||||
|
||||
ALTER TABLE statements ALTER COLUMN statement_type SET DEFAULT 'other';
|
||||
@@ -1,85 +0,0 @@
|
||||
-- Loan statement support.
|
||||
--
|
||||
-- Migration 0013 taught the system that a statement can be a loan; this gives
|
||||
-- loans somewhere to put the data that only loans have.
|
||||
--
|
||||
-- The core accounting problem: a loan repayment is not an expense. A $3,000
|
||||
-- mortgage repayment is roughly $1,200 of principal (a balance-sheet move that
|
||||
-- builds equity) and $1,800 of interest (the only part that is genuinely spend).
|
||||
-- Counting the whole repayment as spending overstates expenses badly.
|
||||
--
|
||||
-- Two statement shapes are handled:
|
||||
-- (a) The common Australian case — the loan statement lists repayments and
|
||||
-- "Interest Charged" as separate rows. transaction_type already carries
|
||||
-- this: 'interest' rows count as spend, 'payment' rows do not.
|
||||
-- (b) Some lenders itemise principal and interest on the repayment row itself.
|
||||
-- That's what principal_amount / interest_amount are for: when
|
||||
-- interest_amount is set, analytics count that instead of the full amount.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
-- Per-transaction principal/interest split (shape (b) above).
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS principal_amount NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS interest_amount NUMERIC(12,2);
|
||||
|
||||
COMMENT ON COLUMN transactions.principal_amount IS
|
||||
'Principal portion of a loan repayment, when the statement itemises it. Not spend.';
|
||||
COMMENT ON COLUMN transactions.interest_amount IS
|
||||
'Interest portion of a loan repayment, when the statement itemises it. This is the part that counts as spend.';
|
||||
|
||||
-- Partial index: only loan repayment rows carry a split.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_interest_amount
|
||||
ON transactions (interest_amount)
|
||||
WHERE interest_amount IS NOT NULL;
|
||||
|
||||
-- Loan-level terms, read off the statement header.
|
||||
ALTER TABLE statements
|
||||
ADD COLUMN IF NOT EXISTS interest_rate NUMERIC(6,3),
|
||||
ADD COLUMN IF NOT EXISTS scheduled_repayment NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS repayment_frequency TEXT,
|
||||
ADD COLUMN IF NOT EXISTS redraw_available NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS loan_term_months INTEGER;
|
||||
|
||||
COMMENT ON COLUMN statements.interest_rate IS 'Annual interest rate as a percentage, e.g. 6.140';
|
||||
COMMENT ON COLUMN statements.redraw_available IS 'Funds available to redraw (loans) — not the same as available_credit on a card.';
|
||||
|
||||
-- Free text varies by lender ("Monthly", "Fortnightly"); normalise the common
|
||||
-- spellings rather than constraining, so an unexpected value never blocks an import.
|
||||
ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_repayment_frequency_check;
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_repayment_frequency(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT := lower(trim(coalesce(raw, '')));
|
||||
BEGIN
|
||||
IF v = '' THEN RETURN NULL; END IF;
|
||||
-- Check fortnightly spellings before the bare '%week%' match below.
|
||||
IF v LIKE '%fortnight%' OR v LIKE '%bi-week%' OR v LIKE '%biweek%'
|
||||
OR v LIKE '%2 week%' OR v LIKE '%two week%' OR v LIKE '%14 day%'
|
||||
THEN RETURN 'fortnightly'; END IF;
|
||||
IF v LIKE '%month%' THEN RETURN 'monthly'; END IF;
|
||||
IF v LIKE '%week%' THEN RETURN 'weekly'; END IF;
|
||||
IF v LIKE '%quarter%' THEN RETURN 'quarterly'; END IF;
|
||||
IF v LIKE '%annual%' OR v LIKE '%year%' THEN RETURN 'annually'; END IF;
|
||||
RETURN v;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_loan_fields_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.repayment_frequency := normalize_repayment_frequency(NEW.repayment_frequency);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_loan_fields ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_loan_fields
|
||||
BEFORE INSERT OR UPDATE OF repayment_frequency ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_loan_fields_trigger();
|
||||
|
||||
UPDATE statements
|
||||
SET repayment_frequency = normalize_repayment_frequency(repayment_frequency)
|
||||
WHERE repayment_frequency IS NOT NULL
|
||||
AND repayment_frequency IS DISTINCT FROM normalize_repayment_frequency(repayment_frequency);
|
||||
@@ -1,137 +0,0 @@
|
||||
-- Canonical category vocabulary, enforced at the database.
|
||||
--
|
||||
-- Categories arrive from three places: the Gemini extraction (which writes
|
||||
-- straight to Postgres from N8N, bypassing the app entirely), CSV import, and
|
||||
-- manual edits. Only the DB sits under all three, so that is where the rule has
|
||||
-- to live -- the same reasoning as normalize_statement_type() in 0013.
|
||||
--
|
||||
-- What had leaked in without it:
|
||||
-- payment 19 rows $42,569.42 transaction_type written into the category
|
||||
-- refund 15 rows $8,558.99 ditto
|
||||
-- Shopping 13 rows $2,917.44 title-case duplicates of real categories,
|
||||
-- Dining 10 rows $127.30 which every GROUP BY counted separately
|
||||
-- (NULL) 3 rows $1,135.05
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_category(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL OR btrim(raw) = '' THEN RETURN 'other'; END IF;
|
||||
|
||||
-- Title-case and spaced variants collapse onto the canonical spelling:
|
||||
-- 'Home Goods' -> 'home_goods', 'Shopping' -> 'shopping'.
|
||||
v := replace(replace(lower(btrim(raw)), ' ', '_'), '-', '_');
|
||||
|
||||
IF v IN ('groceries','dining','transport','fuel','shopping','utilities',
|
||||
'entertainment','travel','health','insurance','subscriptions',
|
||||
'cash_advance','government','education','rent','home_goods',
|
||||
'home_maintenance','transfers','income','investment','loan_interest',
|
||||
'personal_care','pets','gifts','charity','fees','other')
|
||||
THEN RETURN v; END IF;
|
||||
|
||||
-- transaction_type leaking into the category field. A payment is money moving
|
||||
-- to a card or account, which is exactly what 'transfers' means here.
|
||||
IF v IN ('payment','payments','transfer') THEN RETURN 'transfers'; END IF;
|
||||
|
||||
-- Bare 'interest' is credit-card interest; loan interest is categorised
|
||||
-- loan_interest by the extraction prompt and matches the list above.
|
||||
IF v IN ('fee','bank_fees','interest') THEN RETURN 'fees'; END IF;
|
||||
|
||||
-- Singular/plural and common synonyms.
|
||||
IF v IN ('grocery','supermarket') THEN RETURN 'groceries'; END IF;
|
||||
IF v IN ('subscription') THEN RETURN 'subscriptions'; END IF;
|
||||
IF v IN ('utility','bills') THEN RETURN 'utilities'; END IF;
|
||||
IF v IN ('gift') THEN RETURN 'gifts'; END IF;
|
||||
IF v IN ('pet') THEN RETURN 'pets'; END IF;
|
||||
IF v IN ('restaurant','restaurants','food','takeaway') THEN RETURN 'dining'; END IF;
|
||||
IF v IN ('petrol','gas','gasoline') THEN RETURN 'fuel'; END IF;
|
||||
IF v IN ('medical','pharmacy') THEN RETURN 'health'; END IF;
|
||||
IF v IN ('donation','donations') THEN RETURN 'charity'; END IF;
|
||||
IF v IN ('salary','wages') THEN RETURN 'income'; END IF;
|
||||
IF v IN ('investments','savings') THEN RETURN 'investment'; END IF;
|
||||
IF v IN ('housing','mortgage') THEN RETURN 'rent'; END IF;
|
||||
|
||||
-- A 'refund' is not a category -- it says nothing about what was bought. The
|
||||
-- backfill below recovers the real category from the merchant where it can;
|
||||
-- anything still unknown lands in 'other' rather than inventing a category.
|
||||
RETURN 'other';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
|
||||
-- Recover categories for rows typed as refunds before collapsing them to
|
||||
-- 'other': use the most common category that merchant has on ordinary spend.
|
||||
UPDATE transactions t
|
||||
SET category = m.mode_category
|
||||
FROM (
|
||||
SELECT merchant_normalized,
|
||||
MODE() WITHIN GROUP (ORDER BY category) AS mode_category
|
||||
FROM transactions
|
||||
WHERE merchant_normalized IS NOT NULL
|
||||
AND category IS NOT NULL
|
||||
AND category NOT IN ('payment','refund','fee','interest')
|
||||
AND transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY merchant_normalized
|
||||
) m
|
||||
WHERE t.merchant_normalized = m.merchant_normalized
|
||||
AND t.category = 'refund'
|
||||
AND m.mode_category IS NOT NULL;
|
||||
|
||||
|
||||
-- Backfill everything else through the function.
|
||||
UPDATE transactions
|
||||
SET category = normalize_category(category)
|
||||
WHERE category IS NULL OR category <> normalize_category(category);
|
||||
|
||||
UPDATE transaction_overrides
|
||||
SET category_override = normalize_category(category_override)
|
||||
WHERE category_override IS NOT NULL
|
||||
AND category_override <> normalize_category(category_override);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION transactions_normalize_category()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.category := normalize_category(NEW.category);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_transactions_normalize_category ON transactions;
|
||||
CREATE TRIGGER trg_transactions_normalize_category
|
||||
BEFORE INSERT OR UPDATE OF category ON transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION transactions_normalize_category();
|
||||
|
||||
-- Overrides keep NULL meaning "no override"; only a set value is normalised.
|
||||
CREATE OR REPLACE FUNCTION overrides_normalize_category()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.category_override IS NOT NULL THEN
|
||||
NEW.category_override := normalize_category(NEW.category_override);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_overrides_normalize_category ON transaction_overrides;
|
||||
CREATE TRIGGER trg_overrides_normalize_category
|
||||
BEFORE INSERT OR UPDATE OF category_override ON transaction_overrides
|
||||
FOR EACH ROW EXECUTE FUNCTION overrides_normalize_category();
|
||||
|
||||
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_category_check;
|
||||
ALTER TABLE transactions ADD CONSTRAINT transactions_category_check
|
||||
CHECK (category IS NULL OR category IN (
|
||||
'groceries','dining','transport','fuel','shopping','utilities','entertainment',
|
||||
'travel','health','insurance','subscriptions','cash_advance','government',
|
||||
'education','rent','home_goods','home_maintenance','transfers','income',
|
||||
'investment','loan_interest','personal_care','pets','gifts','charity','fees','other'));
|
||||
|
||||
ALTER TABLE transaction_overrides DROP CONSTRAINT IF EXISTS overrides_category_check;
|
||||
ALTER TABLE transaction_overrides ADD CONSTRAINT overrides_category_check
|
||||
CHECK (category_override IS NULL OR category_override IN (
|
||||
'groceries','dining','transport','fuel','shopping','utilities','entertainment',
|
||||
'travel','health','insurance','subscriptions','cash_advance','government',
|
||||
'education','rent','home_goods','home_maintenance','transfers','income',
|
||||
'investment','loan_interest','personal_care','pets','gifts','charity','fees','other'));
|
||||
@@ -1,35 +0,0 @@
|
||||
-- How a transaction was paid for, so cash can be told apart from everything else.
|
||||
--
|
||||
-- The problem this solves: getPendingReconciliations treats every unreconciled
|
||||
-- manual transaction as awaiting a matching statement row. A cash purchase never
|
||||
-- appears on a statement, so it sits in the queue forever and is offered matches
|
||||
-- within 3 days and 1% on amount. Accepting one is silently destructive --
|
||||
-- reconciled manual rows are excluded from every query, so the cash spend
|
||||
-- disappears while the card transaction it matched claims to be that same spend.
|
||||
--
|
||||
-- NULL means unknown, which is treated as reconcilable -- the existing behaviour
|
||||
-- for every row already in the table.
|
||||
--
|
||||
-- Only 'cash' is excluded from reconciliation. A bank transfer DOES appear on a
|
||||
-- statement now that transaction accounts are being imported, so it stays a
|
||||
-- reconciliation candidate.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS payment_method TEXT;
|
||||
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_payment_method_check;
|
||||
ALTER TABLE transactions ADD CONSTRAINT transactions_payment_method_check
|
||||
CHECK (payment_method IS NULL
|
||||
OR payment_method IN ('card', 'cash', 'bank_transfer', 'other'));
|
||||
|
||||
-- Partial index: the reconciliation query filters on this, and cash is expected
|
||||
-- to stay a small minority of rows.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_payment_method
|
||||
ON transactions (payment_method) WHERE payment_method IS NOT NULL;
|
||||
|
||||
-- Backfill the one row that is unambiguously cash. Statement-linked rows are
|
||||
-- left NULL: they came from a statement, so by definition they are not cash.
|
||||
UPDATE transactions
|
||||
SET payment_method = 'cash'
|
||||
WHERE statement_id IS NULL
|
||||
AND description ILIKE '%cash transaction%';
|
||||
@@ -1,28 +0,0 @@
|
||||
-- Record which rule a run came from.
|
||||
--
|
||||
-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is
|
||||
-- not enough to decide whether to revert: you cannot tell a merchant rename from
|
||||
-- a 50/50 split of your entire history. The snapshot column already holds the
|
||||
-- before-state, but nothing said what was applied or why.
|
||||
--
|
||||
-- rule_name is denormalised deliberately. A run must stay readable after the
|
||||
-- rule it came from is edited or deleted -- the history is a record of what
|
||||
-- happened, not a pointer to what the rule says today.
|
||||
|
||||
ALTER TABLE rule_apply_runs
|
||||
ADD COLUMN IF NOT EXISTS rule_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS rule_name TEXT,
|
||||
-- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions;
|
||||
-- 'selection' = one rule against hand-picked transactions (preview → apply).
|
||||
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||
|
||||
ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check;
|
||||
ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check
|
||||
CHECK (source IS NULL OR source IN ('all', 'rule', 'selection'));
|
||||
|
||||
-- No FK to rules: deleting a rule must not cascade away the audit trail.
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule
|
||||
ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL;
|
||||
|
||||
-- Existing rows stay NULL. There is no way to recover which rule they ran;
|
||||
-- the UI shows them as "unknown rule" rather than guessing.
|
||||
@@ -1,40 +0,0 @@
|
||||
-- Create order_reviews table
|
||||
CREATE TABLE IF NOT EXISTS order_reviews (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL UNIQUE REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
rating TEXT,
|
||||
order_again BOOLEAN,
|
||||
note TEXT,
|
||||
item_verdicts JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add source_message_id to expense_metadata
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS source_message_id TEXT;
|
||||
|
||||
-- 1. Admit 'credits' as a payment method.
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_payment_method_check;
|
||||
ALTER TABLE transactions ADD CONSTRAINT transactions_payment_method_check
|
||||
CHECK (payment_method IS NULL OR payment_method IN
|
||||
('card','cash','bank_transfer','credits','other'));
|
||||
|
||||
-- 2. Idempotency key (I7). Partial: rows without an order_reference
|
||||
-- (manual entries) are unaffected.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_source_order
|
||||
ON expense_metadata (source, order_reference)
|
||||
WHERE order_reference IS NOT NULL;
|
||||
|
||||
-- 3. I1 as a database-level guard, not just workflow logic.
|
||||
-- Scoped to pipeline-created rows so manual/statement rows are untouched.
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
|
||||
ALTER TABLE transactions ADD CONSTRAINT chk_ingested_orders_after_cutover
|
||||
CHECK (
|
||||
payment_method IS DISTINCT FROM 'credits'
|
||||
OR transaction_date >= DATE '2026-01-09'
|
||||
);
|
||||
|
||||
-- 4. Constrain the review verdict (§6.1).
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||
CHECK (rating IS NULL OR rating IN ('again','fine','never'));
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Deferred card reconciliation.
|
||||
--
|
||||
-- An order paid "MasterCard Ending in 8032 and/or credits" does not state the
|
||||
-- split. The split is recoverable from the card statement -- but for a live
|
||||
-- order that statement is weeks away, so the split cannot be resolved at ingest
|
||||
-- time. These columns let an order be parked and revisited.
|
||||
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS card_last4 TEXT;
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS currency TEXT;
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS flags JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ;
|
||||
|
||||
-- The pending set: provenance recorded, no transaction yet, still waiting on a
|
||||
-- statement line. Partial so it stays small regardless of table growth.
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_metadata_pending
|
||||
ON expense_metadata (transaction_date)
|
||||
WHERE transaction_id IS NULL AND reconciled_at IS NULL;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Which statement line settled an order's card leg.
|
||||
--
|
||||
-- Without this, reconcileCardLeg has no way to know a charge has already been
|
||||
-- consumed, so two orders on the same card inside the match window both bind to
|
||||
-- it and each books its own credits remainder -- double-counting spend.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS matched_transaction_id INTEGER
|
||||
REFERENCES transactions(id) ON DELETE SET NULL;
|
||||
|
||||
-- One statement line settles at most one order.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_matched_txn
|
||||
ON expense_metadata (matched_transaction_id)
|
||||
WHERE matched_transaction_id IS NOT NULL;
|
||||
@@ -1,39 +0,0 @@
|
||||
-- Order provenance: which platform the receipt came from, and the message it
|
||||
-- came from.
|
||||
--
|
||||
-- The parser has always known the platform (it has to, to read the template)
|
||||
-- and then threw it away. Without it a transaction reads "Order - Burger
|
||||
-- Corner" with no way to tell whether to look in DoorDash or Uber Eats for the
|
||||
-- detail, and no way to answer "how much of this is DoorDash?" at all.
|
||||
--
|
||||
-- `source_email_subject` / `source_email_from` already existed for the
|
||||
-- Paperless expense path and were simply never populated by order ingestion.
|
||||
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS platform text;
|
||||
|
||||
COMMENT ON COLUMN expense_metadata.platform IS
|
||||
'doordash | ubereats | uber — the receipt template the order was read from.';
|
||||
|
||||
-- Backfill the 101 rows written by the 2026-07-27 backfill. DoorDash receipts
|
||||
-- carry no order id of their own, so ingestion synthesises `msg:<message-id>`;
|
||||
-- Uber receipts carry a real trip UUID. That is the only surviving
|
||||
-- discriminator, and it is exact.
|
||||
UPDATE expense_metadata
|
||||
SET platform = CASE WHEN order_reference LIKE 'msg:%' THEN 'doordash' ELSE 'ubereats' END
|
||||
WHERE platform IS NULL
|
||||
AND source = 'email'
|
||||
AND paperless_doc_id IS NULL -- exclude the Paperless expense path
|
||||
AND order_reference IS NOT NULL;
|
||||
|
||||
-- Pick-up / delivery stops, as the receipt prints them. Uber puts these on
|
||||
-- every order under `Order details`; DoorDash prints no addresses at all, so
|
||||
-- this stays '[]' there. Same block a *trip* receipt uses for start and
|
||||
-- destination, so this column already fits rides when they come into scope.
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS route jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN expense_metadata.route IS
|
||||
'Uber only: [{label, time, address}] — pick-up and delivery stops as printed.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_metadata_platform
|
||||
ON expense_metadata (platform)
|
||||
WHERE platform IS NOT NULL;
|
||||
@@ -1,48 +0,0 @@
|
||||
-- Settlement scope: which tab a payment settles.
|
||||
--
|
||||
-- `split_payments` has carried from/to/amount/date since it was written and
|
||||
-- nothing else. That is the whole reason a per-trip balance has never been
|
||||
-- computable — `getTripAnalytics` says so in a comment where the figure should
|
||||
-- be: "split_payments carries no trip attribution, so a payment cannot be
|
||||
-- assigned to a trip. Settlement is a property of the whole relationship."
|
||||
--
|
||||
-- It is also the reason the Shared page silently drops payments the moment a
|
||||
-- tag filter is applied (`getParticipantBalances`): with one global payments
|
||||
-- pool there is no honest way to show a filtered balance, so it showed gross
|
||||
-- splits under the same label instead. A tag is a view; a scope is a ledger.
|
||||
--
|
||||
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
|
||||
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
|
||||
-- already decides which transactions belong to it. A second grouping beside it
|
||||
-- would be two unsynchronised scopes over the same rows — a trip could hold a
|
||||
-- mix of contexts and a context could span trips, with no invariant saying
|
||||
-- which one governs.
|
||||
--
|
||||
-- NULL means the ongoing household tab. That tab never closes, which is why
|
||||
-- this is nullable rather than defaulted to some "general" row: absence is the
|
||||
-- honest representation of "not attached to a trip", and it keeps every
|
||||
-- existing payment correct without a backfill.
|
||||
|
||||
ALTER TABLE split_payments
|
||||
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN split_payments.trip_id IS
|
||||
'The trip this payment settles. NULL = the ongoing household tab.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
|
||||
ON split_payments (trip_id)
|
||||
WHERE trip_id IS NOT NULL;
|
||||
|
||||
-- `settled` answers a different question and the two must not be collapsed:
|
||||
-- trip_id is *which tab*, settled is *is this obligation still live*. A
|
||||
-- pre-2026 historical split is settled with no tab; a Europe split becomes
|
||||
-- settled when Europe's payment lands; a household split stays unsettled and
|
||||
-- open indefinitely.
|
||||
--
|
||||
-- Nothing writes `settled` today. The comment in queries.ts claims
|
||||
-- /api/splits/settle does — that route does not exist, and the column is false
|
||||
-- on all 1,279 rows, which is why every trip has always reported 100%
|
||||
-- unsettled including trips paid in full.
|
||||
|
||||
COMMENT ON COLUMN transaction_splits.settled IS
|
||||
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
|
||||
@@ -1,37 +0,0 @@
|
||||
-- A transaction imported twice cannot simply be deleted.
|
||||
--
|
||||
-- Every child of `transactions` is ON DELETE CASCADE — splits, tags, overrides,
|
||||
-- expense_metadata, order_reviews. Deleting a row said to be "the duplicate"
|
||||
-- therefore destroys whatever curation happens to sit on it, silently and
|
||||
-- unrecoverably. The curation is not reliably on the surviving side either: of
|
||||
-- the 31 known duplicate pairs, one carries splits and six carry overrides, and
|
||||
-- which member holds them is an accident of import order.
|
||||
--
|
||||
-- So a duplicate is superseded, never removed. The row stays, keeps its
|
||||
-- children, and points at the row that replaces it. Reversing a mistake is then
|
||||
-- one UPDATE rather than a restore from backup.
|
||||
--
|
||||
-- This is the statement-vs-statement case. `reconciled_with_id` already covers
|
||||
-- manual-vs-statement, and deliberately cannot be reused: the predicate that
|
||||
-- hides a reconciled row is scoped to `statement_id IS NULL`, because a
|
||||
-- statement line pointing at something else is the survivor, not the duplicate.
|
||||
-- Both of these rows are statement lines.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS superseded_by_id integer
|
||||
REFERENCES transactions(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN transactions.superseded_by_id IS
|
||||
'This row was imported twice; the named row is the one that counts. Excluded from every figure, kept for its children and its audit trail. NULL = live.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_superseded
|
||||
ON transactions (superseded_by_id)
|
||||
WHERE superseded_by_id IS NOT NULL;
|
||||
|
||||
-- A row cannot supersede itself, and a survivor cannot itself be superseded
|
||||
-- (that would hide both members of the pair and lose the amount entirely).
|
||||
ALTER TABLE transactions
|
||||
DROP CONSTRAINT IF EXISTS transactions_no_self_supersede;
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_no_self_supersede
|
||||
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id);
|
||||
@@ -1,52 +0,0 @@
|
||||
-- A four-level verdict, per-item opinions, and one verdict per PERSON.
|
||||
--
|
||||
-- Three levels collapsed the distinction that actually drives a re-order:
|
||||
-- "loved" and "liked" are both "would order again", but only one is worth a
|
||||
-- detour, and "ok" is not a recommendation. Asked for by the user 2026-07-28.
|
||||
--
|
||||
-- Safe as a straight swap: order_reviews had 0 rows when this was written, so
|
||||
-- there are no old values to map. If that ever stops being true, map
|
||||
-- again->liked, fine->ok, never->never BEFORE adding the constraint.
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'never'));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- A verdict belongs to a person, not to an order.
|
||||
--
|
||||
-- A shared meal produces two opinions and they routinely disagree — that
|
||||
-- disagreement is the useful part, and one row per transaction cannot hold it.
|
||||
-- The Slack nudge asks whether the order was shared; a yes both splits the
|
||||
-- expense and asks the other person for their verdict, so the second row is
|
||||
-- the normal case for anything shared, not an edge case.
|
||||
--
|
||||
-- No DEFAULT on participant_id on purpose: a verdict silently attributed to
|
||||
-- whoever happens to be id 1 is worse than an insert that fails loudly.
|
||||
ALTER TABLE order_reviews
|
||||
ADD COLUMN IF NOT EXISTS participant_id integer
|
||||
REFERENCES participants(id) ON DELETE CASCADE;
|
||||
|
||||
UPDATE order_reviews SET participant_id = 1 WHERE participant_id IS NULL;
|
||||
|
||||
ALTER TABLE order_reviews ALTER COLUMN participant_id SET NOT NULL;
|
||||
|
||||
-- Replace the per-transaction uniqueness with per-transaction-per-person.
|
||||
-- Dropping this is what allows the second opinion to exist at all.
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS order_reviews_transaction_id_key;
|
||||
|
||||
ALTER TABLE order_reviews
|
||||
ADD CONSTRAINT order_reviews_transaction_participant_key
|
||||
UNIQUE (transaction_id, participant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- item_verdicts already exists as jsonb DEFAULT '[]'. It has never been
|
||||
-- written. The shape is now fixed as:
|
||||
-- [{"item": "<line item description>", "verdict": "loved"|"never"}]
|
||||
--
|
||||
-- Keyed by description rather than by position in line_items: an index is
|
||||
-- meaningless across orders, and the reusable signal is "the Pad Thai here is
|
||||
-- good", which has to survive into the next order from the same merchant.
|
||||
-- Only the poles are offered — a per-item "ok" is noise nobody would ever read.
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_item_verdicts
|
||||
CHECK (jsonb_typeof(item_verdicts) = 'array');
|
||||
@@ -1,14 +0,0 @@
|
||||
-- A fifth verdict: "bad", between "ok" and "never".
|
||||
--
|
||||
-- "OK" to "Never again" is a big jump and most disappointments live in the gap
|
||||
-- (user, 2026-07-28). Without it, a merely poor meal either flatters itself as
|
||||
-- OK or gets blacklisted, and the blacklist is the signal that has to stay
|
||||
-- sharp — `warn` remains exclusive to 'never' so it is not diluted by every
|
||||
-- mediocre delivery.
|
||||
--
|
||||
-- Safe as a straight widening: no existing row uses a value being removed,
|
||||
-- because nothing is being removed.
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'bad', 'never'));
|
||||
@@ -1,31 +0,0 @@
|
||||
-- Let credits-funded orders exist before the cutover.
|
||||
--
|
||||
-- `chk_ingested_orders_after_cutover` (migration 0018) refused any row with
|
||||
-- payment_method = 'credits' dated before 2026-01-09. It was written as a
|
||||
-- database-level guard for invariant I1, whose stated reason was splits:
|
||||
-- before the cutover, shared expenses lived in SplitMyExpenses, and
|
||||
-- re-importing them would double-charge Sonu against carryover transaction
|
||||
-- 2348.
|
||||
--
|
||||
-- That reason no longer holds. Since finance-app 788219b, ACTIVE_OBLIGATION is
|
||||
-- `ts.settled = false AND t.transaction_date >= '2026-01-09'`, so a split on a
|
||||
-- pre-cutover transaction cannot assert a debt at all. The guard now blocks
|
||||
-- something it was never aimed at: the orders themselves, which are ordinary
|
||||
-- historical spend. 275 of them — $9,799.96 of meals and rides across
|
||||
-- 2020-2025 — were invisible because the receipt was the only record and the
|
||||
-- money came from a gift-card balance rather than a card.
|
||||
--
|
||||
-- What is NOT resolved, and is accepted deliberately (user, 2026-07-28): some
|
||||
-- of those orders were funded by ShopBack gift cards that are themselves
|
||||
-- recorded as expenses, so that portion is counted twice. The exposure is
|
||||
-- bounded at $3,411.16 (14 loads) and is probably smaller, because the
|
||||
-- descriptors name no brand — "ShopBack Gift Cards SQ" is a batch code, and
|
||||
-- the card could be Amazon, Airbnb or Shell as easily as DoorDash. Six are
|
||||
-- categorised `gifts` and may be real presents rather than self-funding.
|
||||
-- Reclassifying them on a guess would corrupt correct data to fix a
|
||||
-- double-count that cannot be demonstrated, so they are left alone; only the
|
||||
-- ShopBack purchase emails can settle it, joined on total paid.
|
||||
--
|
||||
-- The split guard is untouched: this changes what may exist, not what may be
|
||||
-- owed.
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Receipt scans as a second producer on the order lane.
|
||||
--
|
||||
-- A grocery shop paid with a supermarket gift card settles against no statement and
|
||||
-- arrives in no mail, so the pantry scan is the only touchpoint that ever sees it. Rather
|
||||
-- than a second ingestion mechanism, a scan lands as a manual transaction
|
||||
-- (statement_id IS NULL) and the existing pending-reconciliation queue resolves it —
|
||||
-- with needsCardMatch() already excluding cash/credits rows for which no card leg is
|
||||
-- ever coming.
|
||||
|
||||
-- The receipt as text, kept as evidence rather than parsed into a decision. The token
|
||||
-- that distinguishes a gift card from a bank card was only findable by reading two
|
||||
-- payments side by side on one receipt; whether it holds across merchants is answerable
|
||||
-- from stored blocks and not at all from none.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS tender_raw TEXT;
|
||||
|
||||
-- The image the extraction came from. Not the idempotency key -- a photo and the store's
|
||||
-- e-receipt PDF of one purchase hash differently -- but exact where it applies, and the
|
||||
-- only thing a later cross-source dedupe against an emailed or Paperless copy could match
|
||||
-- on.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS receipt_sha256 TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_receipt_sha256
|
||||
ON expense_metadata (receipt_sha256)
|
||||
WHERE receipt_sha256 IS NOT NULL;
|
||||
|
||||
-- Legs of one split-tender shop, so a $40.75 gift-card row can be shown as part of a
|
||||
-- $114.57 purchase instead of an orphan. The shared receipt identity is carried in
|
||||
-- order_reference ('pantry:<merchant:store:register:number:date>#<leg>'); this column is
|
||||
-- what makes the group queryable without parsing that string.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS receipt_group TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_receipt_group
|
||||
ON expense_metadata (receipt_group)
|
||||
WHERE receipt_group IS NOT NULL;
|
||||
@@ -1,54 +0,0 @@
|
||||
-- A feed that re-sends yesterday's rows needs an identity the importer can recognise.
|
||||
--
|
||||
-- The CSV import that exists today is built for a one-off: `batchInsertCSVTransactions`
|
||||
-- assigns `row_index` as MAX(row_index) + 1 over the owner's manual rows, so the same
|
||||
-- file imported twice produces two sets of rows with different indexes. That defeats
|
||||
-- `uq_transaction_identity` (statement_id, transaction_date, description, amount,
|
||||
-- row_index) by construction — the constraint cannot fire, because the fifth column is
|
||||
-- guaranteed fresh on every run. Nothing else stops it either.
|
||||
--
|
||||
-- For a hand-driven bank CSV that is tolerable; the operator sees the file once. For a
|
||||
-- recurring aggregator export it is not, because the windows overlap *by design*: a
|
||||
-- 12-month export pulled weekly re-states ~51 weeks of rows it has already sent. The
|
||||
-- failure would be silent and cumulative, and it is not one we can clean up afterwards —
|
||||
-- every child of `transactions` is ON DELETE CASCADE, so a duplicate must be superseded
|
||||
-- rather than deleted (migration 0023), and ~$42k of re-imported statement rows already
|
||||
-- exist as evidence of how this goes.
|
||||
--
|
||||
-- So the provider's own key travels with the row. Frollo issues a stable per-transaction
|
||||
-- `id`, which is exactly what the wallet-capture lane lacked and could never synthesise
|
||||
-- from a notification. The same two columns serve any future feed that has one.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source IS
|
||||
'Feed this row was ingested from (e.g. ''frollo''). NULL = entered by hand, imported from a statement, or predates the column.';
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source_ref TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source_ref IS
|
||||
'The provider''s own identifier for this transaction, verbatim. Idempotency key for re-imports; never generated locally.';
|
||||
|
||||
-- Partial, so the millions of rows with no source do not have to be unique on (NULL,
|
||||
-- NULL). Enforced in the database rather than in the importer: an ON CONFLICT DO NOTHING
|
||||
-- that silently depends on application-side dedupe is one refactor away from not.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_transaction_source_ref
|
||||
ON transactions (source, source_ref)
|
||||
WHERE source IS NOT NULL AND source_ref IS NOT NULL;
|
||||
|
||||
-- A source row is only half-identified without knowing which account it came from --
|
||||
-- two accounts at the same institution can legitimately carry the same provider id
|
||||
-- namespace. Kept as free text rather than a foreign key: finance-app has no account
|
||||
-- entity, and inventing one to hold a label from an external system would be the tail
|
||||
-- wagging the dog.
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source_account TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source_account IS
|
||||
'Account label as the source system names it, for provenance and for scoping an import to particular accounts. Not an entity reference.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source
|
||||
ON transactions (source, transaction_date)
|
||||
WHERE source IS NOT NULL;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 0029: let the spine bridge write one row PER SHIPMENT of a split order.
|
||||
--
|
||||
-- An Amazon order that ships in two boxes is charged per shipment, so its
|
||||
-- order_total matches no statement line — the bridge (ingestion-engine
|
||||
-- jobs/order_transaction_bridge.py) matches each shipment's own amount and
|
||||
-- items instead. Those rows share (source='order-bridge', order_reference),
|
||||
-- which uq_expense_source_order forbade.
|
||||
--
|
||||
-- The index exists as the MEAL lane's idempotency key (0018 I7), and that
|
||||
-- lane's semantics are untouched: the scope simply excludes bridge rows,
|
||||
-- whose idempotency key is source_message_id (<entity_key> for whole-order
|
||||
-- rows, <entity_key>#f<fact_id> per shipment) — now enforced with its own
|
||||
-- unique index instead of by convention. No app code does ON CONFLICT
|
||||
-- against either index; ingest idempotency is SELECT-based
|
||||
-- (lib/order-ingestion.ts).
|
||||
|
||||
DROP INDEX IF EXISTS uq_expense_source_order;
|
||||
|
||||
CREATE UNIQUE INDEX uq_expense_source_order
|
||||
ON expense_metadata (source, order_reference)
|
||||
WHERE order_reference IS NOT NULL AND source <> 'order-bridge';
|
||||
|
||||
CREATE UNIQUE INDEX uq_expense_bridge_message
|
||||
ON expense_metadata (source, source_message_id)
|
||||
WHERE source = 'order-bridge';
|
||||
@@ -1,127 +0,0 @@
|
||||
-- 0030 — order ↔ transaction, many-to-many (board 205, orders phase 2)
|
||||
--
|
||||
-- Phase 1 read linkage out of `expense_metadata`, which is shaped as ONE ROW
|
||||
-- PER TRANSACTION: `transaction_id` is UNIQUE and `matched_transaction_id`
|
||||
-- partial-unique. Every multiplicity it expresses today is smuggled through a
|
||||
-- string key — migration 0029 keys split shipments `<entity_key>#f<fact_id>` —
|
||||
-- and a BNPL plan needs four rows for one order with no such trick available.
|
||||
--
|
||||
-- The flagship case: order_ebay_14-11714-95953, a A$1,599 DJI drone paid in
|
||||
-- four A$399.75 Afterpay legs (txns 2318, 2333, 1652, 1664). Phase 1 shows it
|
||||
-- with zero linked transactions, which is correct and useless.
|
||||
--
|
||||
-- KEYED ON entity_key, NOT entities.id. finance-app does not model the spine
|
||||
-- and must not carry an FK across an ownership boundary that a spine
|
||||
-- re-extraction can decompile. The real cascade risk is NOT transaction
|
||||
-- deletion (that is handled below) but spine RE-KEYING: supersede_stale()
|
||||
-- decompiles an entity when a re-extraction yields a different entity_key, and
|
||||
-- a TEXT key with no FK silently orphans. Bridge-sourced links are rebuildable;
|
||||
-- `manual` ones are curation and are not. Hence the orphan view at the end.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_transaction_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entity_key TEXT NOT NULL,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
leg_kind TEXT NOT NULL CHECK (leg_kind IN ('charge','shipment','instalment','refund','fee')),
|
||||
-- HUMAN position only: "2 of 4". Never an id.
|
||||
--
|
||||
-- The first draft stored a fact id here. Live shipment keys already carry
|
||||
-- fact ids 22664 and 22711, max(extracted_facts.id) is 23,781 today, and
|
||||
-- SMALLINT tops out at 32,767 — that ceiling arrives on ordinary corpus
|
||||
-- growth and the insert dies with `smallint out of range`. Fact ids go in
|
||||
-- source_fact_id, which is BIGINT.
|
||||
leg_index SMALLINT,
|
||||
leg_count SMALLINT,
|
||||
source_fact_id BIGINT,
|
||||
-- The LEG's own amount, signed: positive is money out, negative is a credit
|
||||
-- coming back. Storing the order total on every leg would make four rows sum
|
||||
-- to four times the purchase.
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'AUD',
|
||||
source TEXT NOT NULL CHECK (source IN ('order-bridge','instalment-matcher','manual')),
|
||||
confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact','derived','manual')),
|
||||
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Every index needs IF NOT EXISTS, not just the table: a run that creates the
|
||||
-- table and then fails during the backfill would otherwise abort on re-run
|
||||
-- with "relation already exists" and need manual surgery.
|
||||
--
|
||||
-- uq_otl_transaction is load-bearing. Without it two matchers can each claim
|
||||
-- txn 2318 and the order reads as paid twice. It is deliberately stricter than
|
||||
-- reality for one case — a single card charge covering two orders — which then
|
||||
-- surfaces as a NAMED REJECTED INSERT rather than as silent duplication.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_transaction ON order_transaction_links (transaction_id);
|
||||
|
||||
-- WHAT IDENTIFIES A LEG DEPENDS ON ITS KIND, and the first version of this
|
||||
-- index got it wrong in the most on-brand way available. It read
|
||||
-- (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of the
|
||||
-- same order — both with a NULL leg_index — collapsed to the same key and
|
||||
-- ON CONFLICT DO NOTHING dropped one SILENTLY. Caught only because the
|
||||
-- backfill reported 62 rows against 63 candidates.
|
||||
--
|
||||
-- The row it ate was order_amazon_249-4859367-0690246's $130.00 second
|
||||
-- shipment (txn 3171) — the same order named in migration 0029's comment as
|
||||
-- the reason split shipments need distinct keys at all, because Amazon mails
|
||||
-- and CHARGES per shipment. A uniqueness rule that cannot hold two shipments
|
||||
-- is the exact defect 0029 was written to fix.
|
||||
--
|
||||
-- So: an instalment leg is identified by its INDEX (1 of 4), a shipment leg by
|
||||
-- its FACT (each shipment is its own extracted fact), and a whole-order charge
|
||||
-- by neither — one per order, which the zeros express.
|
||||
DROP INDEX IF EXISTS uq_otl_leg;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_leg ON order_transaction_links
|
||||
(entity_key, leg_kind, COALESCE(leg_index, 0), COALESCE(source_fact_id, 0));
|
||||
CREATE INDEX IF NOT EXISTS idx_otl_entity ON order_transaction_links (entity_key);
|
||||
|
||||
-- Backfill the existing bridge rows so the table is not empty on day one and
|
||||
-- the readers can switch over in the same change. `#f<fact_id>` splits into a
|
||||
-- shipment leg carrying its fact id; everything else is a whole-order charge.
|
||||
INSERT INTO order_transaction_links
|
||||
(entity_key, transaction_id, leg_kind, leg_index, source_fact_id, amount, currency, source, confidence, evidence)
|
||||
SELECT split_part(em.source_message_id, '#', 1) AS entity_key,
|
||||
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
|
||||
CASE WHEN em.source_message_id LIKE '%#f%' THEN 'shipment' ELSE 'charge' END,
|
||||
NULL::smallint,
|
||||
NULLIF(substring(em.source_message_id FROM '#f([0-9]+)$'), '')::bigint,
|
||||
COALESCE(em.amount, 0),
|
||||
COALESCE(em.currency, 'AUD'),
|
||||
'order-bridge',
|
||||
'exact',
|
||||
jsonb_build_object('backfilled_from', 'expense_metadata',
|
||||
'source_message_id', em.source_message_id)
|
||||
FROM expense_metadata em
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Spine re-keying orphans links silently. Bridge rows can be rebuilt by
|
||||
-- re-running the bridge; a `manual` orphan is lost curation and is the row
|
||||
-- that actually needs a human. Same idea as order_settlement_violations.
|
||||
CREATE OR REPLACE VIEW order_link_orphans AS
|
||||
SELECT l.id, l.entity_key, l.transaction_id, l.leg_kind, l.leg_index,
|
||||
l.amount, l.currency, l.source, l.confidence, l.created_at
|
||||
FROM order_transaction_links l
|
||||
WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.entity_key = l.entity_key);
|
||||
|
||||
-- Two stores that can disagree will disagree. The bridge now writes both in
|
||||
-- ONE transaction, so a half-written pair should be impossible — this view
|
||||
-- exists to prove that rather than to assume it, and it must stay empty.
|
||||
--
|
||||
-- The plan's suggested guard (widen the bridge's `NOT EXISTS expense_metadata`
|
||||
-- to "neither store has it") was NOT taken: the expense_metadata INSERT has no
|
||||
-- ON CONFLICT, so re-attempting an order that already has a receipt row would
|
||||
-- duplicate it. Detecting drift is cheap; re-attempting a non-idempotent write
|
||||
-- to fix a state that cannot occur is not.
|
||||
CREATE OR REPLACE VIEW order_link_drift AS
|
||||
SELECT em.source_message_id,
|
||||
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
|
||||
em.amount, em.currency, em.reconciled_at
|
||||
FROM expense_metadata em
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM order_transaction_links l
|
||||
WHERE l.transaction_id = COALESCE(em.matched_transaction_id, em.transaction_id)
|
||||
);
|
||||
@@ -1,148 +0,0 @@
|
||||
-- Normalise statements.bank_name so one institution is one name.
|
||||
--
|
||||
-- WHY THIS IS NOT COSMETIC: `uq_statement_identity` is
|
||||
-- (bank_name, account_number, billing_end_date). The bank name is a *component
|
||||
-- of the duplicate check*, so two spellings of one institution mean the same
|
||||
-- statement can be ingested twice without the index ever firing. Gemini reads
|
||||
-- the name off whatever the PDF happens to print, so the spelling varies per
|
||||
-- document, not per account.
|
||||
--
|
||||
-- Zip made this visible. One account (2705256) arrived under four names in
|
||||
-- sixteen minutes — 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
|
||||
-- Limited', 'Zip Pay', 'ZipPay' — with ten or more still in the ingestion
|
||||
-- queue. It was not unique: 17 bank_name values represented 13 institutions,
|
||||
-- and every split pair shared an identical account number.
|
||||
--
|
||||
-- American Express (14) / AMERICAN EXPRESS (1) -> XXXX-XXXXXX-01000
|
||||
-- Citibank (8) / Citi (4) -> 5258073330340253
|
||||
-- National Australia Bank Limited (3) / ... (NAB) (1) -> 1015001000116227
|
||||
--
|
||||
-- Fragmentation also splits the statements-page bank filter, the transactions
|
||||
-- bank filter, and by_bank in /api/analytics/fees.
|
||||
--
|
||||
-- THE VOCABULARY IS OPEN, unlike statement_type in 0013. An unrecognised name
|
||||
-- passes through tidied, never collapsed to a fallback: a bank this household
|
||||
-- has never used must be able to arrive without a migration, and destroying its
|
||||
-- name on first contact is far worse than leaving it unmapped. That is also why
|
||||
-- there is deliberately NO CHECK constraint here.
|
||||
--
|
||||
-- Aliases for banks that only ever arrive one way (Westpac, ANZ, HSBC, ING,
|
||||
-- AMP, Up, CommBank) are no-ops today and map TO the spelling already in use,
|
||||
-- so they cannot rename anything. They exist to catch the legal-entity variant
|
||||
-- the next PDF might print — which is exactly how Zip got here.
|
||||
--
|
||||
-- Wise is deliberately left alone: 'Wise Australia Pty Ltd.' is a single
|
||||
-- spelling, so shortening it would be a rename nobody asked for rather than a
|
||||
-- merge. Add a branch if a second spelling ever shows up.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Collapse whitespace before matching so 'Zip Pay' and 'Zip Pay' agree.
|
||||
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
|
||||
|
||||
IF v = '' THEN
|
||||
-- An empty string is not a name. NULL is how the schema already spells
|
||||
-- "unknown": bankLabel() renders it 'Manual' and uq_statement_identity
|
||||
-- excludes it.
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- ---- Merges: institutions seen under more than one spelling ----
|
||||
|
||||
-- 'Zip Pay', 'ZipPay', 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
|
||||
-- Limited'. Anchored so a merchant that merely contains "zip" cannot match.
|
||||
IF v LIKE 'zip%' THEN
|
||||
RETURN 'Zip';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
|
||||
RETURN 'American Express';
|
||||
END IF;
|
||||
|
||||
-- Citi, Citibank, Citigroup — one card, 5258073330340253.
|
||||
IF v LIKE 'citi%' THEN
|
||||
RETURN 'Citibank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
|
||||
RETURN 'NAB';
|
||||
END IF;
|
||||
|
||||
-- ---- Defensive: map future legal-entity variants onto the name in use ----
|
||||
|
||||
IF v LIKE '%westpac%' THEN
|
||||
RETURN 'Westpac';
|
||||
END IF;
|
||||
|
||||
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
|
||||
RETURN 'ANZ';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
|
||||
RETURN 'Commonwealth Bank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'hsbc%' THEN
|
||||
RETURN 'HSBC';
|
||||
END IF;
|
||||
|
||||
IF v = 'ing' OR v LIKE 'ing %' THEN
|
||||
RETURN 'ING';
|
||||
END IF;
|
||||
|
||||
IF v = 'amp' OR v LIKE 'amp %' THEN
|
||||
RETURN 'AMP Bank';
|
||||
END IF;
|
||||
|
||||
IF v = 'up' OR v LIKE 'up bank%' THEN
|
||||
RETURN 'Up';
|
||||
END IF;
|
||||
|
||||
-- Unknown institution: keep exactly what was extracted, tidied only.
|
||||
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Normalise on write so the N8N workflow keeps sending whatever the PDF prints.
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.bank_name := normalize_bank_name(NEW.bank_name);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_bank_name
|
||||
BEFORE INSERT OR UPDATE OF bank_name ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
|
||||
|
||||
-- Backfill. Verified collision-free against uq_statement_identity before
|
||||
-- writing this: no two statements share (normalised name, account_number,
|
||||
-- billing_end_date).
|
||||
UPDATE statements
|
||||
SET bank_name = normalize_bank_name(bank_name)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name);
|
||||
|
||||
-- account_owner_mappings keys on (bank_name, account_number) with its own
|
||||
-- UNIQUE constraint, so a rename on `statements` would strand its rows. The
|
||||
-- table is empty today; this keeps it aligned if it is ever populated before
|
||||
-- this migration runs somewhere else. ON CONFLICT because two spellings of one
|
||||
-- bank could otherwise merge into one key.
|
||||
UPDATE account_owner_mappings
|
||||
SET bank_name = normalize_bank_name(bank_name)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM account_owner_mappings other
|
||||
WHERE other.bank_name = normalize_bank_name(account_owner_mappings.bank_name)
|
||||
AND other.account_number = account_owner_mappings.account_number
|
||||
);
|
||||
@@ -1,164 +0,0 @@
|
||||
-- Keep one institution's national entities apart. Fixes 0031.
|
||||
--
|
||||
-- 0031 merged spellings by prefix — `citi%` -> 'Citibank'. Every merge it made
|
||||
-- was provably one account (each split pair shared an account_number), but the
|
||||
-- PREFIX generalises past that evidence: Citibank Australia and Citibank India
|
||||
-- are different banks, different accounts, different currency, and `citi%`
|
||||
-- flattens them into one. Older Citi India statements are queued to load, so
|
||||
-- this would have merged them on arrival.
|
||||
--
|
||||
-- It is not a Citi problem. `hsbc%` and `%american express%` carry the identical
|
||||
-- defect — all three are global banks operating as separate national entities.
|
||||
--
|
||||
-- WHY NOT DISCRIMINATE ON CURRENCY ALONE: Wise holds AUD, EUR and USD accounts
|
||||
-- under one provider, so a blanket "non-AUD means a different bank" rule would
|
||||
-- shatter it into three. Wise stays unmapped for exactly this reason — mapping
|
||||
-- a genuinely multi-currency provider through this function would be wrong, and
|
||||
-- that is now a documented property rather than an accident.
|
||||
--
|
||||
-- THE RULE: the country comes from the name when the name states it, and from
|
||||
-- the currency otherwise. AUD is home and takes no suffix, so nothing renames
|
||||
-- today — all 21 existing statements keep the name 0031 gave them.
|
||||
--
|
||||
-- 'Citibank' + AUD -> 'Citibank' (unchanged)
|
||||
-- 'Citi' + AUD -> 'Citibank' (unchanged)
|
||||
-- 'Citibank Australia'+ AUD -> 'Citibank'
|
||||
-- 'Citibank India' + INR -> 'Citibank India'
|
||||
-- 'Citibank N.A.' + INR -> 'Citibank India' (currency carries it)
|
||||
-- 'HSBC' + AUD -> 'HSBC' (unchanged)
|
||||
--
|
||||
-- Degradation is safe but not silent: an India statement whose currency was
|
||||
-- extracted as AUD and whose name omits the country WILL merge. The tell is a
|
||||
-- second account_number appearing under one bank_name with a different
|
||||
-- currency — see the check in CLAUDE.md.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
-- 'N.A.' is deliberately absent: it means "National Association", a US legal
|
||||
-- form that appears on Citibank letterhead worldwide, including India. Reading
|
||||
-- it as a country marker is precisely the mistake this migration exists to fix.
|
||||
CREATE OR REPLACE FUNCTION bank_country_suffix(v TEXT, cur TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
c TEXT := upper(trim(coalesce(cur, '')));
|
||||
BEGIN
|
||||
-- The name wins when it states a country: it is the source's own assertion,
|
||||
-- and it survives a currency that failed to extract.
|
||||
IF v LIKE '%australia%' OR v LIKE '% au%' THEN RETURN ''; END IF; -- home
|
||||
IF v LIKE '%india%' THEN RETURN ' India'; END IF;
|
||||
IF v LIKE '%singapore%' THEN RETURN ' Singapore'; END IF;
|
||||
IF v LIKE '%hong kong%' THEN RETURN ' Hong Kong'; END IF;
|
||||
IF v LIKE '%new zealand%' THEN RETURN ' NZ'; END IF;
|
||||
IF v LIKE '%united kingdom%' OR v LIKE '% uk%' THEN RETURN ' UK'; END IF;
|
||||
IF v LIKE '%emirates%' OR v LIKE '% uae%' THEN RETURN ' UAE'; END IF;
|
||||
|
||||
-- Otherwise infer from the statement's own currency. AUD is home.
|
||||
IF c = '' OR c = 'AUD' THEN RETURN ''; END IF;
|
||||
|
||||
IF c = 'INR' THEN RETURN ' India'; END IF;
|
||||
IF c = 'SGD' THEN RETURN ' Singapore'; END IF;
|
||||
IF c = 'HKD' THEN RETURN ' Hong Kong'; END IF;
|
||||
IF c = 'NZD' THEN RETURN ' NZ'; END IF;
|
||||
IF c = 'GBP' THEN RETURN ' UK'; END IF;
|
||||
IF c = 'AED' THEN RETURN ' UAE'; END IF;
|
||||
IF c = 'USD' THEN RETURN ' US'; END IF;
|
||||
|
||||
-- EUR spans countries and cannot name one, so fall back to the code itself.
|
||||
-- A distinguishable name beats a wrong one.
|
||||
RETURN ' ' || c;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- `currency` is a second parameter with a default so any existing single-arg
|
||||
-- call keeps working; the trigger passes NEW.currency.
|
||||
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT, cur TEXT DEFAULT NULL)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
|
||||
|
||||
IF v = '' THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- ---- Merges: institutions seen under more than one spelling ----
|
||||
-- Each carries a country suffix, empty for AUD, so one bank's national
|
||||
-- entities stay apart while its spellings collapse.
|
||||
|
||||
IF v LIKE 'zip%' THEN
|
||||
RETURN 'Zip' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
|
||||
RETURN 'American Express' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'citi%' THEN
|
||||
RETURN 'Citibank' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
|
||||
RETURN 'NAB';
|
||||
END IF;
|
||||
|
||||
-- ---- Defensive: map future legal-entity variants onto the name in use ----
|
||||
|
||||
IF v LIKE '%westpac%' THEN
|
||||
RETURN 'Westpac' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
|
||||
RETURN 'ANZ' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
|
||||
RETURN 'Commonwealth Bank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'hsbc%' THEN
|
||||
RETURN 'HSBC' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'ing' OR v LIKE 'ing %' THEN
|
||||
RETURN 'ING' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'amp' OR v LIKE 'amp %' THEN
|
||||
RETURN 'AMP Bank';
|
||||
END IF;
|
||||
|
||||
IF v = 'up' OR v LIKE 'up bank%' THEN
|
||||
RETURN 'Up';
|
||||
END IF;
|
||||
|
||||
-- Unknown institution: keep exactly what was extracted, tidied only.
|
||||
-- Wise reaches here, which is what keeps its AUD/EUR/USD accounts together.
|
||||
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.bank_name := normalize_bank_name(NEW.bank_name, NEW.currency);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Re-point the trigger: it must fire when `currency` changes too, since the
|
||||
-- country is now partly derived from it.
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_bank_name
|
||||
BEFORE INSERT OR UPDATE OF bank_name, currency ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
|
||||
|
||||
-- Backfill. Expected to touch nothing: every existing statement is AUD except
|
||||
-- Wise, which is unmapped.
|
||||
UPDATE statements
|
||||
SET bank_name = normalize_bank_name(bank_name, currency)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name, currency);
|
||||
@@ -1,71 +0,0 @@
|
||||
-- One account, one identity — without destroying how the number reads.
|
||||
--
|
||||
-- `account_number` carries the same fragmentation `bank_name` had, from the
|
||||
-- same cause: Gemini copies whatever the PDF prints, and the formatting varies
|
||||
-- per document.
|
||||
--
|
||||
-- ANZ '4085-56264' 4 statements ANZ ACCESS ADVANTAGE
|
||||
-- ANZ '408556264' 1 statement ACCESS ADVANTAGE <- same account
|
||||
--
|
||||
-- That matters because `uq_statement_identity` is
|
||||
-- (bank_name, account_number, billing_end_date). Re-importing one period under
|
||||
-- the other spelling evades the duplicate check entirely.
|
||||
--
|
||||
-- (It is NOT what caused the documented 31-row / $42,040.68 ANZ duplication.
|
||||
-- Statements 107/142/143 overlap on *different* end dates, which that index
|
||||
-- cannot catch at any spelling. Different problem, same table.)
|
||||
--
|
||||
-- WHY A GENERATED COLUMN RATHER THAN REWRITING THE VALUE: the raw number is the
|
||||
-- readable one and some of it is structure, not noise. Up stores
|
||||
-- '633-123 / 176540052' — a BSB and an account number — and flattening that to
|
||||
-- '633123176540052' would lose a distinction a human reads at a glance, to fix
|
||||
-- a machine problem. So the raw text stays exactly as extracted and the
|
||||
-- comparison key is derived beside it. Same reason the app derives trip
|
||||
-- participation instead of storing it: two records of one fact drift.
|
||||
--
|
||||
-- Separators only. Case and the Amex mask ('XXXX-XXXXXX-01000') are preserved,
|
||||
-- because 'X' is real information about which digits were redacted, and
|
||||
-- case-folding an alphanumeric account id could merge two genuinely different
|
||||
-- ones.
|
||||
--
|
||||
-- Verified collision-free before rebuilding the index: no two statements share
|
||||
-- (bank_name, stripped account_number, billing_end_date).
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
ALTER TABLE statements
|
||||
ADD COLUMN IF NOT EXISTS account_number_key TEXT
|
||||
GENERATED ALWAYS AS (
|
||||
NULLIF(regexp_replace(coalesce(account_number, ''), '[^0-9A-Za-z]', '', 'g'), '')
|
||||
) STORED;
|
||||
|
||||
COMMENT ON COLUMN statements.account_number_key IS
|
||||
'Derived from account_number, separators stripped. The identity used by '
|
||||
'uq_statement_identity so one account under two formats is one account. '
|
||||
'Never write to it — it is GENERATED. Display account_number instead.';
|
||||
|
||||
-- Rebuild the identity index on the derived key. Dropped and recreated rather
|
||||
-- than added alongside: leaving the old one in place would keep admitting the
|
||||
-- duplicate it exists to stop.
|
||||
DROP INDEX IF EXISTS uq_statement_identity;
|
||||
CREATE UNIQUE INDEX uq_statement_identity
|
||||
ON statements (bank_name, account_number_key, billing_end_date)
|
||||
WHERE bank_name IS NOT NULL AND billing_end_date IS NOT NULL;
|
||||
|
||||
-- The fragmentation detector, now able to see both halves: one account under
|
||||
-- two bank names, or one bank under two spellings of one number.
|
||||
CREATE OR REPLACE VIEW statement_identity_drift AS
|
||||
SELECT account_number_key,
|
||||
array_agg(DISTINCT bank_name) AS bank_names,
|
||||
array_agg(DISTINCT account_number) AS account_numbers,
|
||||
count(*) AS statements
|
||||
FROM statements
|
||||
WHERE account_number_key IS NOT NULL
|
||||
GROUP BY account_number_key
|
||||
HAVING count(DISTINCT bank_name) > 1
|
||||
OR count(DISTINCT account_number) > 1;
|
||||
|
||||
COMMENT ON VIEW statement_identity_drift IS
|
||||
'Non-empty means an account is fragmented: normalize_bank_name() cannot know '
|
||||
'that two names for a bank it has never seen are one institution, and this is '
|
||||
'how that always shows up. Check it after loading a new bank.';
|
||||
@@ -1,74 +0,0 @@
|
||||
-- Intake router (plans/intake-router-slice-plan.md §11 C1, smarthome repo).
|
||||
--
|
||||
-- One upload point for receipts — a screenshot, a PDF export, a pasted body —
|
||||
-- that reads what it was given and does every step that applies: fill the
|
||||
-- empty `line_items` on the order the email pipeline already booked, hand a
|
||||
-- grocery receipt to pantry, or ask when it cannot tell.
|
||||
--
|
||||
-- Every batch is persisted BEFORE anything is extracted, for two reasons:
|
||||
-- 1. The Slack card it produces carries buttons ("Use this", "Create",
|
||||
-- "Groceries or a meal?") that are pressed minutes later in a fresh
|
||||
-- request with nothing but the batch id to go on.
|
||||
-- 2. A re-share of an already-handled order — a second crop, the PDF after
|
||||
-- the screenshot — must be recognised, and the only durable evidence of
|
||||
-- the first share is this row (`file_sha256` overlap, or the same
|
||||
-- (platform, merchant, date, total) header already resolved).
|
||||
--
|
||||
-- `id` is the uuid n8n mints per webhook call, so a retried delivery is the
|
||||
-- same batch rather than a second one. `header` holds the extracted
|
||||
-- (merchant, order_date, total, platform, ...) and `items` the verbatim item
|
||||
-- list, so a later tap can enrich or create without re-reading the files.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intake_batches (
|
||||
id uuid PRIMARY KEY,
|
||||
source text NOT NULL,
|
||||
shared_by text,
|
||||
file_sha256 text[] NOT NULL DEFAULT '{}',
|
||||
header jsonb,
|
||||
items jsonb,
|
||||
kind text,
|
||||
status text NOT NULL,
|
||||
transaction_id integer,
|
||||
capture_id integer,
|
||||
-- The undo baseline: what `expense_metadata.line_items` held before this
|
||||
-- batch first replaced it. Recorded here rather than returned from the write,
|
||||
-- because pantry re-calls the enrich endpoint on every confirm and after a
|
||||
-- timeout — a caller that hands back whatever it just read gives a retry its
|
||||
-- OWN write as the "previous" state, and undo then restores the thing it was
|
||||
-- meant to reverse. Written under COALESCE, so only the first snapshot sticks.
|
||||
previous_line_items jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
resolved_at timestamptz
|
||||
);
|
||||
|
||||
-- For a database that already ran an earlier draft of this file.
|
||||
ALTER TABLE intake_batches ADD COLUMN IF NOT EXISTS previous_line_items jsonb;
|
||||
|
||||
COMMENT ON TABLE intake_batches IS
|
||||
'One row per upload batch through POST /api/intake. Persisted before '
|
||||
'extraction so Slack taps can act on it later and re-shares are detected. '
|
||||
'kind: grocery|food|ask|held|unsupported. status: received|enriched|created|'
|
||||
'candidates|forwarded_to_pantry|needs_header|duplicate|unsupported|held|error.';
|
||||
|
||||
-- Overlap lookups for the re-share check (`file_sha256 && $1`).
|
||||
CREATE INDEX IF NOT EXISTS intake_batches_file_sha256_gin
|
||||
ON intake_batches USING gin (file_sha256);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS intake_batches_transaction_id
|
||||
ON intake_batches (transaction_id) WHERE transaction_id IS NOT NULL;
|
||||
|
||||
-- The "Groceries or a meal?" answer, remembered per merchant (plan §2, Codex
|
||||
-- #6). A tiny answer table, not merchant master-data: the pattern list in code
|
||||
-- decides the obvious cases and this catches the `Romeo's Food Hall`s. Keyed on
|
||||
-- the normalised merchant name the router already computes for matching.
|
||||
CREATE TABLE IF NOT EXISTS intake_merchant_kinds (
|
||||
merchant_key text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('grocery', 'food')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE intake_merchant_kinds IS
|
||||
'Per-merchant answer to "Groceries or a meal?" from the intake Slack card. '
|
||||
'Consulted after the grocery pattern list; an unknown merchant asks once.';
|
||||
@@ -1,73 +0,0 @@
|
||||
-- transaction_search: resolve expense_metadata by BOTH links, without fanning out.
|
||||
--
|
||||
-- THE DEFECT (measured 2026-08-22): the view joined only on
|
||||
-- `em.transaction_id = t.id`, so it silently omitted every card-settled order,
|
||||
-- which carries only `matched_transaction_id`. 148 transactions are affected --
|
||||
-- Glovo orders in Milan, Uber Eats, all `source='order-bridge'` -- and for each
|
||||
-- of them the view reports NULL payment_method / order_reference / line_items
|
||||
-- while the data sits in the table.
|
||||
--
|
||||
-- That is not cosmetic. This view is the agent read surface: Hermes'
|
||||
-- postgres-personal MCP selects payment_method and order_reference from it
|
||||
-- (personal-agent-gateway/mcp-servers/postgres-personal/queries.py), and
|
||||
-- home-dashboard reads it via dash_ro. CLAUDE.md states the rule the view broke:
|
||||
-- "That lookup must match transaction_id OR matched_transaction_id: a
|
||||
-- card-settled order has only the latter."
|
||||
--
|
||||
-- NOT a fan-out fix, contrary to a first reading. The original join CANNOT fan
|
||||
-- out: expense_metadata_transaction_id_key is UNIQUE on transaction_id, so it
|
||||
-- matched at most one row by construction. Sums taken over this view were never
|
||||
-- inflated.
|
||||
--
|
||||
-- But broadening the join is exactly what INTRODUCES that risk, which is why
|
||||
-- LATERAL is used rather than a second OR'd condition. transaction_id and
|
||||
-- matched_transaction_id are enforced by two SEPARATE unique indexes
|
||||
-- (expense_metadata_transaction_id_key, uq_expense_matched_txn), so one
|
||||
-- transaction can legally be pointed at by two DIFFERENT rows -- one via each
|
||||
-- link. Zero such transactions exist today; the LATERAL means that stays a
|
||||
-- non-event rather than a silent duplicate in the agent's spending totals.
|
||||
--
|
||||
-- Tie-break matches every other reader (getTransactions, replaceLineItems,
|
||||
-- matchTransaction, /api/transactions/[id]/order), so the row this view resolves
|
||||
-- is the row the UI shows.
|
||||
--
|
||||
-- Verified before applying: row count unchanged at 4760 (= count(transactions)),
|
||||
-- sum(amount) over debits unchanged at 1514412.89 -- amount comes from
|
||||
-- transactions, never from the metadata row -- and the only difference is the
|
||||
-- 148 rows that gain the metadata they always should have had.
|
||||
--
|
||||
-- CREATE OR REPLACE VIEW DROPS GRANTS. dash_ro (home-dashboard) and agent_ro
|
||||
-- (Hermes) both read this view and losing either breaks a surface with no error
|
||||
-- at replace time, so both are re-granted below.
|
||||
|
||||
CREATE OR REPLACE VIEW transaction_search AS
|
||||
SELECT t.id,
|
||||
t.transaction_date,
|
||||
t.description,
|
||||
COALESCE(t.merchant_normalized, t.merchant_name) AS merchant,
|
||||
t.category,
|
||||
t.transaction_type,
|
||||
COALESCE(t.amount_aud, t.amount) AS amount,
|
||||
t.foreign_currency_amount,
|
||||
t.foreign_currency_code,
|
||||
t.location,
|
||||
s.bank_name,
|
||||
s.card_name,
|
||||
s.account_number,
|
||||
s.statement_type,
|
||||
s.billing_end_date,
|
||||
em.payment_method,
|
||||
em.order_reference,
|
||||
em.line_items
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT em2.payment_method, em2.order_reference, em2.line_items
|
||||
FROM expense_metadata em2
|
||||
WHERE em2.transaction_id = t.id OR em2.matched_transaction_id = t.id
|
||||
ORDER BY (em2.transaction_id = t.id) DESC NULLS LAST, em2.id
|
||||
LIMIT 1
|
||||
) em ON true;
|
||||
|
||||
GRANT SELECT ON transaction_search TO dash_ro;
|
||||
GRANT SELECT ON transaction_search TO agent_ro;
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Normalise foreign_currency_code to ISO-4217, and clear two sentinel values.
|
||||
--
|
||||
-- WHY THIS MATTERS: exact-code equality is the whole basis of FX reconciliation.
|
||||
-- ingestion-engine's order_transaction_bridge matches foreign orders with
|
||||
-- AND t.foreign_currency_code = %(ccy)s
|
||||
-- AND t.foreign_currency_amount = %(total)s
|
||||
-- so a row labelled 'BAHT' can never match a THB order, and a row labelled
|
||||
-- 'null' can never match anything. 226 of 1,632 coded rows (14%) were
|
||||
-- unmatchable for this reason alone, which silently degrades a shipped job.
|
||||
--
|
||||
-- THE SENTINELS. 187 rows carry the literal 4-character STRING 'null' rather
|
||||
-- than SQL NULL, and 42 of those also carry foreign_currency_amount = -1.00
|
||||
-- exactly. Neither is data: they are "no value" markers that survived a truthy
|
||||
-- guard. Root cause is in the CC statement processors' Parse node, which does
|
||||
-- foreign_currency_code: t.foreign_currency_code || null
|
||||
-- and a non-empty string is truthy, so a model that answers with the STRING
|
||||
-- "null" (rather than JSON null) passes straight through. Same for -1. Fixed
|
||||
-- workflow-side in the same change; this migration cleans what already landed.
|
||||
-- Not purely historical -- 155 rows in 2026-03, but also 8 in May, 3 in July
|
||||
-- and 21 in August 2026.
|
||||
--
|
||||
-- MAPPINGS WERE VERIFIED AGAINST IMPLIED RATE, NOT TRUSTED FROM THE LABEL,
|
||||
-- because a row whose rate contradicts its claimed currency is a broken ledger
|
||||
-- row rather than a mislabelled one (the trap order_transaction_bridge's rate
|
||||
-- guard exists for: 2 of its first 73 matches were broken rows, not bad
|
||||
-- matches). Median amount/foreign_currency_amount per raw code vs the ISO
|
||||
-- median over statement rows (n>=5):
|
||||
-- BAHT 0.0433 vs THB 0.0470 INDIAN RUPEE 0.0183 vs INR 0.0180
|
||||
-- U.A.E. DIRHAM 0.4287 vs AED 0.4184 US DOLLAR 1.4054 vs USD 1.5513
|
||||
-- NZ DOLLAR 0.8243 vs NZD 0.9311 EURO 1.6342 vs EUR 1.6964
|
||||
-- Every label agrees with its rate. foreign_currency_amount is untouched by the
|
||||
-- relabels -- only the code was ever wrong, so this is a relabel, never a
|
||||
-- recompute.
|
||||
--
|
||||
-- EXPECTED ROW COUNTS (verified pre-flight, includes superseded rows):
|
||||
-- code 'null' -> NULL 187 BAHT -> THB 13
|
||||
-- amount -1.00 -> NULL 42 U.A.E. DIRHAM -> AED 10
|
||||
-- NZ DOLLAR -> NZD 6 US DOLLAR -> USD 4
|
||||
-- INDIAN RUPEE -> INR 4 EURO -> EUR 2
|
||||
-- Pre-flight also confirmed: no -1.00 amount exists outside the 'null'-code
|
||||
-- rows, and no other non-ISO code exists.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Sentinels first: the -1.00 amounts live only on 'null'-code rows, so clear
|
||||
-- them before that code is nulled and the set becomes unfindable.
|
||||
UPDATE transactions SET foreign_currency_amount = NULL
|
||||
WHERE foreign_currency_amount = -1.00 AND foreign_currency_code = 'null';
|
||||
|
||||
UPDATE transactions SET foreign_currency_code = NULL
|
||||
WHERE foreign_currency_code = 'null';
|
||||
|
||||
-- Word-form relabels.
|
||||
UPDATE transactions SET foreign_currency_code = 'THB' WHERE foreign_currency_code = 'BAHT';
|
||||
UPDATE transactions SET foreign_currency_code = 'AED' WHERE foreign_currency_code = 'U.A.E. DIRHAM';
|
||||
UPDATE transactions SET foreign_currency_code = 'NZD' WHERE foreign_currency_code = 'NZ DOLLAR';
|
||||
UPDATE transactions SET foreign_currency_code = 'USD' WHERE foreign_currency_code = 'US DOLLAR';
|
||||
UPDATE transactions SET foreign_currency_code = 'INR' WHERE foreign_currency_code = 'INDIAN RUPEE';
|
||||
UPDATE transactions SET foreign_currency_code = 'EUR' WHERE foreign_currency_code = 'EURO';
|
||||
|
||||
-- Postcondition: every remaining code is ISO-4217 shaped. Fails the whole
|
||||
-- transaction rather than leaving a partial normalisation behind.
|
||||
DO $$
|
||||
DECLARE bad int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO bad FROM transactions
|
||||
WHERE foreign_currency_code IS NOT NULL AND foreign_currency_code !~ '^[A-Z]{3}$';
|
||||
IF bad <> 0 THEN
|
||||
RAISE EXCEPTION 'normalisation incomplete: % non-ISO codes remain', bad;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,152 +0,0 @@
|
||||
-- Append-only change log for the ledger and its curation children.
|
||||
--
|
||||
-- WHY THIS EXISTS: on 2026-08-23 the CC Statement Processor's `Run Reconciliation`
|
||||
-- node stamped `statement_id` onto seven statementless wallet/pantry rows without
|
||||
-- setting `reconciled_with_id` or superseding the statement's own extracted line.
|
||||
-- The purchase counted twice, and because finance-app's reconcile queue is
|
||||
-- `WHERE statement_id IS NULL`, the stamp also hid those rows from the only thing
|
||||
-- that could repair them. Answering "was anything lost?" required restoring the
|
||||
-- 02:30 pg_dump into a scratch database and diffing it column by column, because
|
||||
-- NOTHING in this estate records what changed. That is the gap this closes.
|
||||
--
|
||||
-- WHY AT THE DATABASE AND NOT IN THE APP: the ledger has at least five independent
|
||||
-- writers -- finance-app (Prisma), n8n (raw SQL in a Postgres node), pantry-app,
|
||||
-- the ingestion-engine runner (psycopg), and ad-hoc `docker exec psql`. The 4a bug
|
||||
-- was an n8n raw-SQL UPDATE; an application-level audit would not have seen it at
|
||||
-- all. A trigger cannot be bypassed by choice of client.
|
||||
--
|
||||
-- WHY NOT POSTGRES' OWN LOGS: checked live on this instance -- wal_level=replica
|
||||
-- (so WAL cannot be logically decoded into row changes), wal_keep_size=0 and
|
||||
-- archive_mode=off (so WAL is recycled almost immediately), log_statement=none,
|
||||
-- logging_collector=off, track_commit_timestamp=off, and pgaudit is not installed.
|
||||
-- Postgres was recording nothing usable. It has no built-in audit or temporal
|
||||
-- tables; this always has to be built.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_audit (
|
||||
id bigserial PRIMARY KEY,
|
||||
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Groups every row touched by one logical edit. `applyReconcileMatch` moves
|
||||
-- overrides, tags, splits and metadata in a single transaction; without this
|
||||
-- they read as four unrelated events.
|
||||
txid bigint NOT NULL DEFAULT txid_current(),
|
||||
table_name text NOT NULL,
|
||||
op text NOT NULL CHECK (op IN ('INSERT','UPDATE','DELETE')),
|
||||
-- Full primary key as an object, because transaction_tags is composite
|
||||
-- (transaction_id, tag_id) and the others are a bare `id`.
|
||||
row_key jsonb NOT NULL,
|
||||
-- Denormalised so "everything that ever happened to transaction N" is one
|
||||
-- index scan rather than a five-way union.
|
||||
transaction_id bigint,
|
||||
-- On UPDATE these hold ONLY the columns that actually changed. Storing whole
|
||||
-- rows would bury a one-column edit in forty unchanged fields, and
|
||||
-- expense_metadata.line_items alone can be kilobytes.
|
||||
old_data jsonb,
|
||||
new_data jsonb,
|
||||
-- Attribution is partial and that is understood: every client connects as the
|
||||
-- same `personal` role. app_name is only as good as what each client sets;
|
||||
-- client_addr distinguishes containers and is NULL for `docker exec` over the
|
||||
-- unix socket, which is itself a useful signal.
|
||||
db_user text NOT NULL DEFAULT current_user,
|
||||
app_name text,
|
||||
client_addr inet
|
||||
);
|
||||
|
||||
COMMENT ON TABLE transaction_audit IS
|
||||
'Append-only row-level change log for transactions + curation children. Never UPDATE or DELETE from this table.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS transaction_audit_txn_time
|
||||
ON transaction_audit (transaction_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS transaction_audit_time
|
||||
ON transaction_audit (changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS transaction_audit_txid
|
||||
ON transaction_audit (txid);
|
||||
CREATE INDEX IF NOT EXISTS transaction_audit_table_time
|
||||
ON transaction_audit (table_name, changed_at DESC);
|
||||
|
||||
-- TG_ARGV[0]: comma-separated candidate columns for the owning transaction id;
|
||||
-- the first non-null wins (expense_metadata carries the id in
|
||||
-- transaction_id OR matched_transaction_id -- a card-settled order
|
||||
-- only ever has the latter).
|
||||
-- TG_ARGV[1]: comma-separated primary key columns.
|
||||
CREATE OR REPLACE FUNCTION log_transaction_change() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $fn$
|
||||
DECLARE
|
||||
v_full jsonb;
|
||||
v_old jsonb;
|
||||
v_new jsonb;
|
||||
v_key jsonb := '{}'::jsonb;
|
||||
v_txn bigint;
|
||||
k text;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_new := to_jsonb(NEW);
|
||||
v_full := v_new;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
v_old := to_jsonb(OLD);
|
||||
v_full := v_old;
|
||||
ELSE
|
||||
v_old := to_jsonb(OLD);
|
||||
v_new := to_jsonb(NEW);
|
||||
v_full := v_new;
|
||||
-- Reduce to the changed columns only. IS DISTINCT FROM so a NULL->NULL
|
||||
-- comparison counts as unchanged rather than evaluating to NULL.
|
||||
FOR k IN SELECT jsonb_object_keys(v_old) LOOP
|
||||
IF (v_old -> k) IS NOT DISTINCT FROM (v_new -> k) THEN
|
||||
v_old := v_old - k;
|
||||
v_new := v_new - k;
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- A no-op UPDATE is noise, and finance-app's upserts issue plenty of them.
|
||||
IF v_old = '{}'::jsonb THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
FOREACH k IN ARRAY string_to_array(TG_ARGV[1], ',') LOOP
|
||||
v_key := v_key || jsonb_build_object(k, v_full -> k);
|
||||
END LOOP;
|
||||
|
||||
FOREACH k IN ARRAY string_to_array(TG_ARGV[0], ',') LOOP
|
||||
IF v_txn IS NULL AND (v_full ->> k) IS NOT NULL THEN
|
||||
v_txn := (v_full ->> k)::bigint;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
INSERT INTO transaction_audit
|
||||
(table_name, op, row_key, transaction_id, old_data, new_data, app_name, client_addr)
|
||||
VALUES
|
||||
(TG_TABLE_NAME, TG_OP, v_key, v_txn, v_old, v_new,
|
||||
NULLIF(current_setting('application_name', true), ''), inet_client_addr());
|
||||
|
||||
RETURN NULL; -- AFTER trigger; return value is ignored
|
||||
END
|
||||
$fn$;
|
||||
|
||||
-- AFTER, so a statement that raises never leaves a phantom audit row; the audit
|
||||
-- write is in the same transaction as the change and rolls back with it.
|
||||
DROP TRIGGER IF EXISTS trg_audit_transactions ON transactions;
|
||||
CREATE TRIGGER trg_audit_transactions
|
||||
AFTER INSERT OR UPDATE OR DELETE ON transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('id', 'id');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_transaction_splits ON transaction_splits;
|
||||
CREATE TRIGGER trg_audit_transaction_splits
|
||||
AFTER INSERT OR UPDATE OR DELETE ON transaction_splits
|
||||
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'id');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_transaction_tags ON transaction_tags;
|
||||
CREATE TRIGGER trg_audit_transaction_tags
|
||||
AFTER INSERT OR UPDATE OR DELETE ON transaction_tags
|
||||
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'transaction_id,tag_id');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_transaction_overrides ON transaction_overrides;
|
||||
CREATE TRIGGER trg_audit_transaction_overrides
|
||||
AFTER INSERT OR UPDATE OR DELETE ON transaction_overrides
|
||||
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'id');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_expense_metadata ON expense_metadata;
|
||||
CREATE TRIGGER trg_audit_expense_metadata
|
||||
AFTER INSERT OR UPDATE OR DELETE ON expense_metadata
|
||||
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id,matched_transaction_id', 'id');
|
||||
@@ -1,76 +0,0 @@
|
||||
-- Account continuity across a rebrand, reissue or issuer change.
|
||||
--
|
||||
-- WHY THIS EXISTS: one card can appear in `statements` under two completely
|
||||
-- different identities. The live case is the Citi Prestige card, which moved to
|
||||
-- NAB and was rebranded MyCard:
|
||||
--
|
||||
-- Citibank 5258073330340253 statements ... 2025-11-13
|
||||
-- NAB 1015001000116227 statements 2025-11-14 ...
|
||||
--
|
||||
-- Same 14th-to-13th billing cycle, no gap, one continuous product. Nothing in
|
||||
-- the schema linked them, so anything grouping by (bank_name, account) sees a
|
||||
-- dead account and a new one with no history. For the annual-fee detector that
|
||||
-- is not cosmetic: the $700 fee charged 2024-12-13 and 2025-11-13 reads as
|
||||
-- "stopped" when it is due again around November 2026 under the new name.
|
||||
--
|
||||
-- WHY A TABLE AND NOT AN INFERENCE: contiguous-cycle heuristics would join two
|
||||
-- genuinely different accounts as readily as one renamed one, and a wrong join
|
||||
-- merges two real fee series. There are only a handful of these events in a
|
||||
-- lifetime, each announced by mail, so each link is asserted once with the
|
||||
-- document that proves it. `evidence` is not decoration -- it is how a future
|
||||
-- reader checks the claim instead of trusting it.
|
||||
--
|
||||
-- NOT `account_owner_mappings`: that answers "whose account is this" and keys on
|
||||
-- the raw (bank_name, account_number). This answers "is this the same account as
|
||||
-- that one", and keys on the normalised form.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_links (
|
||||
id bigserial PRIMARY KEY,
|
||||
-- Normalised the same way statements.account_number_key is generated
|
||||
-- (non-alphanumerics stripped), so these join without per-call cleanup.
|
||||
-- Amex-style masks keep their X's: they record which digits were redacted.
|
||||
from_bank_name text NOT NULL,
|
||||
from_account_key text NOT NULL,
|
||||
to_bank_name text NOT NULL,
|
||||
to_account_key text NOT NULL,
|
||||
-- When the successor took over. The predecessor's last statement and the
|
||||
-- successor's first are the observable boundary; this is the announced date
|
||||
-- where mail gives one.
|
||||
effective_date date,
|
||||
reason text NOT NULL
|
||||
CHECK (reason IN ('rebrand', 'reissue', 'issuer_change', 'migration')),
|
||||
-- Provenance for the claim, e.g. 'source_documents:32919'. Free text because
|
||||
-- the evidence is not always a document -- sometimes it is a statement pair.
|
||||
evidence text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- One successor per predecessor: a chain, never a fork. A fork would make
|
||||
-- "the current identity of this account" ambiguous, and the resolver walks
|
||||
-- this forward assuming it is not.
|
||||
CONSTRAINT uq_account_links_from UNIQUE (from_bank_name, from_account_key),
|
||||
-- A link to itself would make that walk non-terminating.
|
||||
CONSTRAINT account_links_no_self CHECK (
|
||||
from_bank_name IS DISTINCT FROM to_bank_name
|
||||
OR from_account_key IS DISTINCT FROM to_account_key
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE account_links IS
|
||||
'Predecessor -> successor account identity across rebrands/reissues. Hand-asserted, each row carrying the evidence for its claim.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS account_links_to
|
||||
ON account_links (to_bank_name, to_account_key);
|
||||
|
||||
-- The one link the mail record proves today.
|
||||
-- source_documents 32919 (2025-10-01) "your Citi Prestige Card account is moving to MyCard"
|
||||
-- source_documents 32618 (2025-11-10) "MyCard is coming soon"
|
||||
-- source_documents 32539 (2025-11-24) "The new MyCard App and MyCard Online are now live"
|
||||
-- Statement boundary: Citibank ends 2025-11-13, NAB begins 2025-11-14.
|
||||
INSERT INTO account_links
|
||||
(from_bank_name, from_account_key, to_bank_name, to_account_key, effective_date, reason, evidence, notes)
|
||||
VALUES
|
||||
('Citibank', '5258073330340253', 'NAB', '1015001000116227', '2025-11-14', 'issuer_change',
|
||||
'source_documents:32919,32618,32539',
|
||||
'Citi Prestige -> MyCard Prestige. Billing cycle (14th-13th) continues unbroken across the boundary.')
|
||||
ON CONFLICT (from_bank_name, from_account_key) DO NOTHING;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- When, and from which app, a wallet notification arrived.
|
||||
--
|
||||
-- WHY: one tap emits two notifications 2-8 seconds apart -- the issuer app
|
||||
-- (card tail) and Apple Wallet (merchant, suburb) -- and wallet-ingestion.ts
|
||||
-- merges them. It used to recognise the pair by amount alone, within +/-1 DAY,
|
||||
-- which is wide enough to fold two different purchases together: on 2026-10-01
|
||||
-- an Amex "$50.00 at A MEDIUM" notification merged into the UNICEF $50 donation
|
||||
-- from 17 hours earlier and renamed it. A real pair is seconds apart and comes
|
||||
-- from two DIFFERENT apps; two notifications from one app are two charges.
|
||||
-- Neither fact was stored anywhere on the ledger side, so the matcher could not
|
||||
-- ask. These columns let it.
|
||||
--
|
||||
-- Backfilled from capture.wallet_events through source_message_id ('evt:<id>'),
|
||||
-- which every wallet-written metadata row carries.
|
||||
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS notified_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS notif_app text;
|
||||
|
||||
UPDATE expense_metadata em
|
||||
SET notified_at = e.received_at,
|
||||
notif_app = e.notif_app
|
||||
FROM capture.wallet_events e
|
||||
WHERE em.source = 'wallet'
|
||||
AND em.source_message_id = 'evt:' || e.id
|
||||
AND em.notified_at IS NULL;
|
||||
@@ -1,43 +0,0 @@
|
||||
-- A statementless row for a charge that never posted.
|
||||
--
|
||||
-- WHY: the wallet lane books every card notification as a purchase, and a card
|
||||
-- notification is sent for an AUTHORISATION, not a settlement. When the
|
||||
-- merchant releases the hold the bank never posts the charge, so no statement
|
||||
-- line ever arrives: the row sits in the reconcile queue forever and counts as
|
||||
-- spend in every figure. Txn 7951: Airbnb HMXAHAW49Y, $1,040.41 held on Amex
|
||||
-- 1000 on 2026-09-24, booking cancelled 25 Sep inside the free window, hold
|
||||
-- released ("We removed the $1,040.41 (AUD) payment hold"). Statement 219
|
||||
-- (11 Sep - 10 Oct) has no such line and reconciles to its header without it.
|
||||
--
|
||||
-- Neither existing exclusion fits. reconciled_with_id needs a statement twin
|
||||
-- and superseded_by_id needs a surviving row (and may not point at itself,
|
||||
-- transactions_no_self_supersede). There is no survivor: nothing happened.
|
||||
--
|
||||
-- Not a DELETE: every child of transactions cascades, and the row is also the
|
||||
-- sink the wallet lane checks before re-posting an event. Deleting it would let
|
||||
-- the next prompt_version bump re-create it.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS voided_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS void_reason text;
|
||||
|
||||
COMMENT ON COLUMN transactions.voided_at IS
|
||||
'Set when this statementless row records a charge that never posted (e.g. a released authorisation hold). Excluded from every figure and from the reconcile queue, kept for its children and audit trail. NULL = live.';
|
||||
|
||||
-- A reason is mandatory: a void with no explanation is indistinguishable from
|
||||
-- a mistake six months later.
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_void_has_reason
|
||||
CHECK ((voided_at IS NULL AND void_reason IS NULL)
|
||||
OR (voided_at IS NOT NULL AND btrim(coalesce(void_reason, '')) <> ''));
|
||||
|
||||
-- Statement rows cannot be voided. A statement line is the bank's own record
|
||||
-- of a posted charge, and the statement's rows must keep summing to its
|
||||
-- header; a wrong statement row is a supersede, not a void.
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_void_statementless
|
||||
CHECK (voided_at IS NULL OR statement_id IS NULL);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_voided
|
||||
ON transactions (voided_at)
|
||||
WHERE voided_at IS NOT NULL;
|
||||
+9
-220
@@ -7,30 +7,13 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model trips {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
description String?
|
||||
start_date DateTime? @db.Date
|
||||
end_date DateTime? @db.Date
|
||||
color String @default("#6366f1")
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
overrides transaction_overrides[]
|
||||
payments split_payments[]
|
||||
}
|
||||
|
||||
model transaction_overrides {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int @unique
|
||||
merchant_normalized String?
|
||||
category_override String?
|
||||
notes String?
|
||||
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
trip_id Int?
|
||||
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model participants {
|
||||
@@ -40,9 +23,6 @@ model participants {
|
||||
created_at DateTime @default(now())
|
||||
splits transaction_splits[]
|
||||
account_owner_mappings account_owner_mappings[]
|
||||
payments_sent split_payments[] @relation("payments_from")
|
||||
payments_received split_payments[] @relation("payments_to")
|
||||
order_reviews order_reviews[]
|
||||
}
|
||||
|
||||
model account_owner_mappings {
|
||||
@@ -69,23 +49,6 @@ model transaction_splits {
|
||||
@@unique([transaction_id, participant_id])
|
||||
}
|
||||
|
||||
model split_payments {
|
||||
id Int @id @default(autoincrement())
|
||||
from_participant_id Int
|
||||
to_participant_id Int
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
payment_date DateTime @db.Date
|
||||
notes String?
|
||||
linked_transaction_id Int?
|
||||
trip_id Int?
|
||||
created_at DateTime @default(now())
|
||||
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
||||
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
||||
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([trip_id])
|
||||
}
|
||||
|
||||
model tags {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
@@ -104,16 +67,15 @@ model transaction_tags {
|
||||
}
|
||||
|
||||
model rules {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
manual_only Boolean @default(false)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
model budgets {
|
||||
@@ -127,176 +89,3 @@ model budgets {
|
||||
|
||||
@@unique([owner_id, category, month])
|
||||
}
|
||||
|
||||
model statements {
|
||||
id Int @id @default(autoincrement())
|
||||
filename String
|
||||
bank_name String?
|
||||
card_name String?
|
||||
account_type String?
|
||||
account_number String
|
||||
/// GENERATED ALWAYS (migration 0033) — account_number with separators
|
||||
/// stripped, so '4085-56264' and '408556264' are one account. Backs
|
||||
/// uq_statement_identity. Read-only: never write to it, display
|
||||
/// account_number instead.
|
||||
account_number_key String?
|
||||
billing_start_date DateTime? @db.Date
|
||||
billing_end_date DateTime? @db.Date
|
||||
total_amount_due Decimal? @db.Decimal(12, 2)
|
||||
minimum_amount_due Decimal? @db.Decimal(12, 2)
|
||||
payment_due_date DateTime? @db.Date
|
||||
event_created Boolean? @default(false)
|
||||
tier_used String?
|
||||
created_at DateTime? @default(now())
|
||||
statement_type String @default("other")
|
||||
currency String? @default("AUD")
|
||||
opening_balance Decimal? @db.Decimal(12, 2)
|
||||
closing_balance Decimal? @db.Decimal(12, 2)
|
||||
total_credits Decimal? @db.Decimal(12, 2)
|
||||
total_debits Decimal? @db.Decimal(12, 2)
|
||||
interest_charged Decimal? @db.Decimal(12, 2)
|
||||
fees_charged Decimal? @db.Decimal(12, 2)
|
||||
credit_limit Decimal? @db.Decimal(12, 2)
|
||||
available_credit Decimal? @db.Decimal(12, 2)
|
||||
owner_id Int @default(1)
|
||||
account_holder_name String?
|
||||
exchange_rate_to_aud Decimal? @db.Decimal(10, 6)
|
||||
paperless_doc_id Int? @unique
|
||||
interest_rate Decimal? @db.Decimal(6, 3)
|
||||
scheduled_repayment Decimal? @db.Decimal(12, 2)
|
||||
repayment_frequency String?
|
||||
redraw_available Decimal? @db.Decimal(12, 2)
|
||||
loan_term_months Int?
|
||||
transactions transactions[]
|
||||
}
|
||||
|
||||
model transactions {
|
||||
id Int @id @default(autoincrement())
|
||||
statement_id Int?
|
||||
transaction_date DateTime @db.Date
|
||||
description String?
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
created_at DateTime? @default(now())
|
||||
transaction_type String? @default("debit")
|
||||
merchant_name String?
|
||||
location String?
|
||||
foreign_currency_amount Decimal? @db.Decimal(12, 2)
|
||||
foreign_currency_code String?
|
||||
category String?
|
||||
row_index Int?
|
||||
merchant_normalized String?
|
||||
amount_aud Decimal? @db.Decimal(12, 2)
|
||||
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
|
||||
owner_id Int?
|
||||
reconciled_with_id Int?
|
||||
superseded_by_id Int?
|
||||
voided_at DateTime? @db.Timestamptz(6) // a charge that never posted (migration 0040)
|
||||
void_reason String?
|
||||
principal_amount Decimal? @db.Decimal(12, 2)
|
||||
interest_amount Decimal? @db.Decimal(12, 2)
|
||||
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
|
||||
source_ref String? // the provider's own transaction id — idempotency key for re-imports
|
||||
source_account String? // account label as the source names it
|
||||
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
|
||||
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
|
||||
reconciled_by transactions[] @relation("reconciled")
|
||||
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
|
||||
supersedes transactions[] @relation("superseded")
|
||||
expense_metadata expense_metadata?
|
||||
order_reviews order_reviews[]
|
||||
}
|
||||
|
||||
model expense_metadata {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int? @unique
|
||||
source String @default("email")
|
||||
paperless_doc_id Int? @unique
|
||||
source_email_subject String?
|
||||
source_email_from String?
|
||||
source_message_id String?
|
||||
payment_method String?
|
||||
payment_method_detail String?
|
||||
order_reference String?
|
||||
line_items Json @default("[]")
|
||||
tax_amount Decimal? @db.Decimal(12, 2)
|
||||
subtotal Decimal? @db.Decimal(12, 2)
|
||||
merchant_normalized String?
|
||||
amount Decimal? @db.Decimal(12, 2)
|
||||
transaction_date DateTime? @db.Date
|
||||
extraction_model String? @default("gemini-2.5-flash")
|
||||
created_at DateTime? @default(now())
|
||||
// In the database since migrations 0019/0020 but absent from this model until 0027.
|
||||
// Regenerating the client from the stale definition would have dropped columns the order
|
||||
// lane writes on every ingest.
|
||||
card_last4 String?
|
||||
currency String?
|
||||
flags Json @default("[]")
|
||||
reconciled_at DateTime? @db.Timestamptz(6)
|
||||
matched_transaction_id Int?
|
||||
platform String?
|
||||
route Json?
|
||||
// 0027 — receipt scans as a second producer on this lane.
|
||||
tender_raw String?
|
||||
receipt_sha256 String?
|
||||
receipt_group String?
|
||||
// 0039 — when/where a wallet notification arrived; the pair merge needs both.
|
||||
notified_at DateTime? @db.Timestamptz(6)
|
||||
notif_app String?
|
||||
transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([source, order_reference], name: "uq_expense_source_order")
|
||||
}
|
||||
|
||||
model order_reviews {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int
|
||||
participant_id Int
|
||||
rating String?
|
||||
order_again Boolean?
|
||||
note String?
|
||||
item_verdicts Json @default("[]")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
transaction transactions @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
participant participants @relation(fields: [participant_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([transaction_id, participant_id], name: "order_reviews_transaction_participant_key")
|
||||
}
|
||||
|
||||
model rule_apply_runs {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
applied_at DateTime @default(now())
|
||||
split_from DateTime? @db.Date
|
||||
matched Int @default(0)
|
||||
transactions_affected Int @default(0)
|
||||
reverted_at DateTime?
|
||||
snapshot Json @default("[]")
|
||||
}
|
||||
|
||||
// 0034 — intake router. One row per upload batch, written before extraction so
|
||||
// Slack taps can act on it later and a re-share is detected. Columns are the
|
||||
// §11 C1 contract in plans/intake-router-slice-plan.md (smarthome repo).
|
||||
model intake_batches {
|
||||
id String @id @db.Uuid
|
||||
source String
|
||||
shared_by String?
|
||||
file_sha256 String[] @default([])
|
||||
header Json?
|
||||
items Json?
|
||||
kind String?
|
||||
status String
|
||||
transaction_id Int?
|
||||
capture_id Int?
|
||||
// The undo baseline, written once per batch (see 0034 and line-items.ts).
|
||||
previous_line_items Json?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
resolved_at DateTime? @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
// 0034 — the remembered answer to "Groceries or a meal?" per merchant.
|
||||
model intake_merchant_kinds {
|
||||
merchant_key String @id
|
||||
kind String
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# Personal Finance Tracker — Project Context
|
||||
|
||||
## What Is This?
|
||||
|
||||
A self-hosted personal finance tracker built from scratch. It automatically ingests bank statements, categorises transactions using AI, and provides a web UI for reviewing spending, managing shared expenses, and running analytics.
|
||||
|
||||
---
|
||||
|
||||
## How It Works (High Level)
|
||||
|
||||
### Automatic Statement Ingestion
|
||||
|
||||
Bank statements (PDFs) are uploaded to a document management system (Paperless-NGX). An automation workflow (N8N) polls for new documents every 5 minutes and:
|
||||
|
||||
1. Sends the PDF to Google Gemini (AI) for structured data extraction
|
||||
2. Normalises the extracted data (merchant names, currencies, account numbers)
|
||||
3. Inserts the statement summary and individual transactions into a PostgreSQL database
|
||||
4. Creates a Google Calendar reminder for credit card payment due dates
|
||||
5. For new/unknown bank accounts, requires a human approval before inserting
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### Transactions View
|
||||
- Full paginated list of all transactions across all bank accounts
|
||||
- Filters: date range, category, bank, tags, transaction type, amount range, split status, free-text search
|
||||
- Sortable columns including transaction date, amount, and import date
|
||||
- Inline editing of category, merchant name, and notes
|
||||
- Tagging system with user-defined coloured labels
|
||||
|
||||
### Statements View
|
||||
- One row per billing period per account
|
||||
- Filters by bank, statement type, owner, year
|
||||
- Click a statement to see only its transactions
|
||||
|
||||
### Analytics / Insights
|
||||
- Monthly spend breakdown by category (stacked bar chart)
|
||||
- Category trend lines over time
|
||||
- Pareto chart (which categories drive the most spend)
|
||||
- Cumulative spend curve
|
||||
- Savings rate over time
|
||||
- Recurring charge detection (subscriptions/recurring merchants)
|
||||
- Fees and interest audit (tracks what's been paid in bank fees and interest charges)
|
||||
- Committed vs discretionary spend split
|
||||
|
||||
### Merchant Profiles
|
||||
- Per-merchant transaction history and net spend
|
||||
- Scatter plot of spend over time
|
||||
- Accounts for refunds/credits
|
||||
|
||||
### Shared Expenses
|
||||
- Split transactions between multiple people (by percentage)
|
||||
- Tracks who owes what with a running balance
|
||||
- Record cash settlements between participants
|
||||
- Tag filter on shared view to track specific projects/events
|
||||
|
||||
### Rules Engine
|
||||
- Create saved rules that auto-apply categories, merchant names, tags, or splits to matching transactions
|
||||
- Conditions: merchant name, description, category, bank, amount, transaction type
|
||||
- Operators: contains, equals, starts with, greater/less than, not equals
|
||||
- Bulk-apply all rules at once with full revert support (snapshot stored before each run)
|
||||
|
||||
### Manual Transactions + Reconciliation
|
||||
- Enter transactions manually (cash, receipts not on a statement)
|
||||
- CSV import for bulk entry
|
||||
- Reconcile manual transactions against statement transactions when the statement arrives — merges tags, splits, and notes onto the statement version
|
||||
|
||||
### Multi-Owner Support
|
||||
- Multiple people's accounts can be tracked in the one app
|
||||
- Each statement/transaction is scoped to an owner
|
||||
- The logged-in user only sees their own data
|
||||
|
||||
---
|
||||
|
||||
## Technology
|
||||
|
||||
- **Frontend**: Next.js (React), TypeScript, Tailwind CSS, Recharts for charts
|
||||
- **Backend**: Next.js API routes with raw PostgreSQL queries (no ORM at query time)
|
||||
- **Database**: PostgreSQL
|
||||
- **AI extraction**: Google Gemini 2.5 Flash (PDF → structured JSON)
|
||||
- **Automation**: N8N workflow orchestrates the ingestion pipeline
|
||||
- **Auth**: Users are authenticated by the reverse proxy before reaching the app
|
||||
- **Hosting**: Self-hosted Docker container on a home server
|
||||
|
||||
---
|
||||
|
||||
## Data Structure (Summary)
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Statement** | One billing period for one bank account. Has summary totals (closing balance, fees, interest, credit limit, etc.) |
|
||||
| **Transaction** | A line item. Has date, amount, merchant, category, transaction type (debit/credit/refund/fee/etc.) |
|
||||
| **Override** | User correction to AI-extracted merchant name or category. Stored separately to preserve the original AI output |
|
||||
| **Split** | A transaction shared with another person — records their percentage share and whether it's been settled |
|
||||
| **Tag** | A free-form label applied to transactions (e.g. "Europe Trip 2026", "Home Reno") |
|
||||
| **Rule** | A saved condition→action pair applied in bulk to auto-categorise/tag/split transactions |
|
||||
| **Participant** | A person (account owner or expense-sharing partner) |
|
||||
| **Split Payment** | A recorded cash settlement between two participants |
|
||||
|
||||
### Category Taxonomy
|
||||
|
||||
Fixed set used by AI and overridable by the user:
|
||||
`groceries`, `dining`, `transport`, `fuel`, `shopping`, `utilities`, `entertainment`, `travel`, `health`, `insurance`, `subscriptions`, `cash_advance`, `government`, `education`, `rent`, `home_goods`, `home_maintenance`, `transfers`, `income`, `investment`, `personal_care`, `pets`, `gifts`, `charity`, `other`
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations / Planned Work
|
||||
|
||||
- **Payment provider conflation**: Transactions processed through PayPal, Afterpay, Zip etc. sometimes show the payment provider as the merchant rather than the actual store. Plan is to extract `payment_provider` as a separate field so the real merchant is preserved.
|
||||
- **Budgets**: The database has a budget table but it's not currently surfaced in the UI (the analytics/insights views replaced it for now).
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Resolve the integration-test DSN at run time and (re)write .env.test.
|
||||
#
|
||||
# Why this exists (#161): .env.test used to be written ONCE by
|
||||
# setup-test-db.sh with the Postgres container's bridge IP baked in. Container
|
||||
# IPs are reassigned on recreation, so the file rotted silently — by
|
||||
# 2026-08-21 its 172.22.0.18 belonged to *gitea*, and an earlier drift had the
|
||||
# suite pointing its TRUNCATE at postgres-pantry. It only failed safe because
|
||||
# the credentials happened not to match.
|
||||
#
|
||||
# The fix is to stop persisting a host at all: this runs as `pretest:integration`,
|
||||
# so the DSN is re-derived from the live container immediately before every run.
|
||||
#
|
||||
# Resolution order:
|
||||
# 1. An explicit TEST_DATABASE_URL in the environment wins (CI, remote DB).
|
||||
# 2. The container's published host port (`docker port`) — postgres-personal
|
||||
# publishes 127.0.0.1:5432 since the 2026-08-17 PG17 migration. Preferred
|
||||
# because a loopback address cannot be reassigned to another container.
|
||||
# 3. The container's bridge IP, for a host where the port is not published.
|
||||
#
|
||||
# helpers.ts::assertTestDatabase is the backstop: it refuses to TRUNCATE
|
||||
# anything whose current_database() is not `personal_test`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
|
||||
PG_USER=${PG_USER:-personal}
|
||||
PG_PASS=${PG_PASS:-personalpassword123}
|
||||
TEST_DB=${TEST_DB:-personal_test}
|
||||
|
||||
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env.test"
|
||||
|
||||
if [ -n "${TEST_DATABASE_URL:-}" ]; then
|
||||
DSN="$TEST_DATABASE_URL"
|
||||
SOURCE="TEST_DATABASE_URL"
|
||||
else
|
||||
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: container '$PG_CONTAINER' not found. Start it, or set TEST_DATABASE_URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `docker port` prints e.g. "127.0.0.1:5432" (possibly several lines).
|
||||
PUBLISHED=$(docker port "$PG_CONTAINER" 5432/tcp 2>/dev/null | head -1 || true)
|
||||
|
||||
if [ -n "$PUBLISHED" ]; then
|
||||
HOSTPORT="$PUBLISHED"
|
||||
SOURCE="published port"
|
||||
else
|
||||
IP=$(docker inspect "$PG_CONTAINER" \
|
||||
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' 2>/dev/null \
|
||||
| tr ' ' '\n' | grep -v '^$' | head -1)
|
||||
if [ -z "$IP" ]; then
|
||||
echo "ERROR: '$PG_CONTAINER' has neither a published 5432 nor a bridge IP." >&2
|
||||
exit 1
|
||||
fi
|
||||
HOSTPORT="$IP:5432"
|
||||
SOURCE="bridge IP"
|
||||
fi
|
||||
|
||||
DSN="postgresql://$PG_USER:$PG_PASS@$HOSTPORT/$TEST_DB"
|
||||
fi
|
||||
|
||||
printf 'DATABASE_URL=%s\n' "$DSN" > "$ENV_FILE"
|
||||
|
||||
# Never print the password.
|
||||
echo "resolve-test-db: ${DSN%%://*}://***@${DSN#*@} (via $SOURCE)"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Creates the personal_test database and copies the production schema into it.
|
||||
# Run once (and after any migration) before integration tests: npm run test:setup
|
||||
#
|
||||
# It no longer writes a host into .env.test. That was the #161 defect: the file
|
||||
# was written once with the container's bridge IP and then rotted as the IP was
|
||||
# reassigned. scripts/resolve-test-db.sh now re-derives the DSN before every
|
||||
# integration run (wired as `pretest:integration`), and this script just calls it
|
||||
# at the end so a fresh setup leaves a usable .env.test behind.
|
||||
|
||||
set -e
|
||||
|
||||
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
|
||||
PG_USER=${PG_USER:-personal}
|
||||
TEST_DB=${TEST_DB:-personal_test}
|
||||
|
||||
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: Could not find container '$PG_CONTAINER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Drop and recreate, so the schema is rebuilt rather than replayed. Replaying
|
||||
# the dump over an existing database was a silent no-op for every migration:
|
||||
# each CREATE TABLE failed with "already exists" and the old shape survived, so
|
||||
# a new column (0039's expense_metadata.notified_at, 2026-10-03) never reached
|
||||
# the test database. The guard is because this now DROPs: it refuses anything
|
||||
# not named *_test, whatever TEST_DB was set to.
|
||||
case "$TEST_DB" in
|
||||
*_test) ;;
|
||||
*) echo "ERROR: refusing to drop '$TEST_DB' -- the test database must be named *_test"; exit 1 ;;
|
||||
esac
|
||||
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -q \
|
||||
-c "DROP DATABASE IF EXISTS $TEST_DB WITH (FORCE);" \
|
||||
-c "CREATE DATABASE $TEST_DB;"
|
||||
|
||||
echo "Copying schema from $PG_USER to $TEST_DB..."
|
||||
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
|
||||
| docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q -v ON_ERROR_STOP=1
|
||||
|
||||
echo "Schema ready."
|
||||
|
||||
bash "$(dirname "$0")/resolve-test-db.sh"
|
||||
|
||||
echo "Integration tests can now run with: npm run test:integration"
|
||||
@@ -1,376 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Match SplitMyExpenses CSV rows to transactions already in the ledger.
|
||||
|
||||
Dry-run by default. It prints what it would do and writes nothing; `--write`
|
||||
is a separate step (task #9) and is deliberately not implemented here.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The CSVs are the record of how expenses were actually shared before this app
|
||||
existed. Importing them is what makes historical *spend* correct: without a
|
||||
split row, a $200 grocery shop counts as $200 of my spending when half of it
|
||||
was never mine. The balances are already settled by carryover transaction 2348,
|
||||
so these splits are imported as `settled = true` and move no balance.
|
||||
|
||||
Three things make the matching harder than "same date, same amount":
|
||||
|
||||
1. **Dates are ambiguous across files.** The household file writes D/M/YYYY;
|
||||
the four trip files write ISO. 474 rows parse validly under both readings,
|
||||
so the format is decided per file, from the file, and never guessed per row.
|
||||
|
||||
2. **The sign convention is not "who paid".** A person's column is their net
|
||||
balance impact: positive means they are owed. So the payer is whoever is
|
||||
positive, and the other person's share is |their negative| / cost. A row
|
||||
reading +cost / -cost therefore means the other party owes 100% -- NOT that
|
||||
the expense was unshared, which is the reading that would fake an
|
||||
arrangement change.
|
||||
|
||||
3. **A settlement is not an expense.** Rows where one person hands the other
|
||||
money must not become split transactions; they are already represented by
|
||||
the carryover.
|
||||
|
||||
Usage:
|
||||
.venv/bin/python scripts/split_csv_match.py [--verbose] [--file NAME]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DUMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dump")
|
||||
|
||||
# Read from the same place the app does rather than hardcoding a container IP,
|
||||
# which changes on every recreate.
|
||||
def db_url() -> str:
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
return url
|
||||
for envfile in (".env", ".env.test"):
|
||||
path = os.path.join(os.path.dirname(DUMP_DIR), envfile)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
for line in open(path):
|
||||
if line.startswith("DATABASE_URL"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
sys.exit("No DATABASE_URL found (env, .env, or .env.test)")
|
||||
|
||||
|
||||
# The two people in these files. The CSV writes full names; the ledger uses
|
||||
# first names.
|
||||
CSV_ME = "Siddharth Bose"
|
||||
CSV_THEM = "Meghalee"
|
||||
PARTICIPANT_ME = 1
|
||||
PARTICIPANT_THEM = 4
|
||||
|
||||
# A settlement transfers money; it is not a shared cost. These are the
|
||||
# descriptions SplitMyExpenses uses for them.
|
||||
SETTLEMENT_PAT = re.compile(
|
||||
r"payment|settle|debts? remainder|reimburse|transfer to|paid back", re.I
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CsvRow:
|
||||
source: str
|
||||
line: int
|
||||
when: date
|
||||
description: str
|
||||
category: str
|
||||
cost: float
|
||||
currency: str
|
||||
net_me: float
|
||||
net_them: float
|
||||
|
||||
# Filled in by classify()
|
||||
mode: str = ""
|
||||
payer: int = 0 # participant id who paid
|
||||
ower: int = 0 # participant id who owes
|
||||
ower_share: float = 0.0 # 0-100
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.source}:{self.line} {self.when} {self.description[:38]!r} ${self.cost:.2f} [{self.mode}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchReport:
|
||||
rows: list = field(default_factory=list)
|
||||
matched: list = field(default_factory=list)
|
||||
ambiguous: list = field(default_factory=list)
|
||||
unmatched: list = field(default_factory=list)
|
||||
skipped: list = field(default_factory=list)
|
||||
|
||||
|
||||
def sniff_date_format(sample: list[str]) -> str:
|
||||
"""Decide ISO vs D/M/YYYY for a whole file.
|
||||
|
||||
Deciding per row is what produces a ledger where January and February are
|
||||
silently swapped for some rows and not others. A file is written by one
|
||||
exporter in one format, so the file is the unit of decision.
|
||||
"""
|
||||
if not sample:
|
||||
return "%Y-%m-%d"
|
||||
slashes = sum(1 for s in sample if "/" in s)
|
||||
return "%d/%m/%Y" if slashes > len(sample) / 2 else "%Y-%m-%d"
|
||||
|
||||
|
||||
def parse_rows(path: str) -> list[CsvRow]:
|
||||
with open(path, newline="", encoding="utf-8-sig") as fh:
|
||||
reader = list(csv.DictReader(fh))
|
||||
if not reader:
|
||||
return []
|
||||
|
||||
# Header names vary in quoting between exports.
|
||||
def col(row: dict, *names: str):
|
||||
for n in names:
|
||||
for k in row:
|
||||
if k.strip().strip('"') == n:
|
||||
return row[k]
|
||||
return None
|
||||
|
||||
fmt = sniff_date_format([col(r, "Date") or "" for r in reader[:40]])
|
||||
out: list[CsvRow] = []
|
||||
for i, r in enumerate(reader, start=2):
|
||||
raw_date = (col(r, "Date") or "").strip()
|
||||
try:
|
||||
when = datetime.strptime(raw_date, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
cost = float(col(r, "Cost") or 0)
|
||||
net_me = float(col(r, CSV_ME) or 0)
|
||||
net_them = float(col(r, CSV_THEM) or 0)
|
||||
except ValueError:
|
||||
continue
|
||||
out.append(
|
||||
CsvRow(
|
||||
source=os.path.basename(path),
|
||||
line=i,
|
||||
when=when,
|
||||
description=(col(r, "Description") or "").strip(),
|
||||
category=(col(r, "Category") or "").strip(),
|
||||
cost=cost,
|
||||
currency=(col(r, "Currency") or "AUD").strip(),
|
||||
net_me=net_me,
|
||||
net_them=net_them,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def classify(row: CsvRow) -> CsvRow:
|
||||
"""Work out who paid and what share the other person owes.
|
||||
|
||||
A person's column is their net balance impact, not their share: positive
|
||||
means they are owed money. So the payer is whoever is positive.
|
||||
"""
|
||||
if row.cost == 0:
|
||||
row.mode = "zero-cost"
|
||||
return row
|
||||
|
||||
if SETTLEMENT_PAT.search(row.description):
|
||||
row.mode = "settlement"
|
||||
return row
|
||||
|
||||
# Both zero against a real cost: recorded but not shared.
|
||||
if abs(row.net_me) < 0.005 and abs(row.net_them) < 0.005:
|
||||
row.mode = "unshared"
|
||||
return row
|
||||
|
||||
if row.net_me > 0:
|
||||
row.payer, row.ower, owed = PARTICIPANT_ME, PARTICIPANT_THEM, abs(row.net_them)
|
||||
else:
|
||||
row.payer, row.ower, owed = PARTICIPANT_THEM, PARTICIPANT_ME, abs(row.net_me)
|
||||
|
||||
row.ower_share = round(owed / row.cost * 100, 2)
|
||||
if abs(row.ower_share - 50) < 0.6:
|
||||
row.mode = "50/50"
|
||||
elif abs(row.ower_share - 100) < 0.6:
|
||||
row.mode = "other-owes-all"
|
||||
else:
|
||||
row.mode = f"uneven-{row.ower_share:.0f}"
|
||||
return row
|
||||
|
||||
|
||||
def norm(s: str) -> set[str]:
|
||||
return {w for w in re.split(r"[^a-z0-9]+", (s or "").lower()) if len(w) > 2}
|
||||
|
||||
|
||||
def score(row: CsvRow, tx: dict) -> float:
|
||||
"""How well a ledger row matches a CSV row. Amount and date gate it;
|
||||
description only ranks among survivors."""
|
||||
days = abs((tx["transaction_date"] - row.when).days)
|
||||
s = 100.0 - days * 4
|
||||
overlap = norm(row.description) & (norm(tx["description"]) | norm(tx["merchant_normalized"]))
|
||||
s += 12 * len(overlap)
|
||||
return s
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--file", help="only this CSV (substring match)")
|
||||
ap.add_argument("--window", type=int, default=5, help="date tolerance in days")
|
||||
ap.add_argument(
|
||||
"--write", action="store_true",
|
||||
help="actually insert the splits (settled=true). Without this, nothing is written.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
paths = sorted(glob.glob(os.path.join(DUMP_DIR, "*SplitMyExpenses*.csv")))
|
||||
if args.file:
|
||||
paths = [p for p in paths if args.file in os.path.basename(p)]
|
||||
if not paths:
|
||||
sys.exit("No SplitMyExpenses CSVs found in dump/")
|
||||
|
||||
conn = psycopg2.connect(db_url())
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# Superseded rows are duplicates; matching against them would attach a split
|
||||
# to a row nothing else counts.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.transaction_date, COALESCE(t.description,'') AS description,
|
||||
COALESCE(t.merchant_normalized,'') AS merchant_normalized,
|
||||
COALESCE(t.amount_aud, t.amount)::float AS amount,
|
||||
t.superseded_by_id,
|
||||
EXISTS(SELECT 1 FROM transaction_splits x WHERE x.transaction_id=t.id) AS has_split
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, s.owner_id) IN (%s, %s)
|
||||
AND t.superseded_by_id IS NULL
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND t.transaction_type IN ('debit','fee','interest')
|
||||
""",
|
||||
(PARTICIPANT_ME, PARTICIPANT_THEM),
|
||||
)
|
||||
txs = cur.fetchall()
|
||||
|
||||
# Index by rounded amount: the amount must agree, so it is the only cheap
|
||||
# gate that never needs fuzzy comparison.
|
||||
by_amount: dict[float, list[dict]] = {}
|
||||
for t in txs:
|
||||
by_amount.setdefault(round(t["amount"], 2), []).append(t)
|
||||
|
||||
rep = MatchReport()
|
||||
modes: Counter = Counter()
|
||||
candidates: list = []
|
||||
for path in paths:
|
||||
for row in parse_rows(path):
|
||||
classify(row)
|
||||
rep.rows.append(row)
|
||||
modes[row.mode] += 1
|
||||
|
||||
if row.mode in ("settlement", "zero-cost", "unshared"):
|
||||
rep.skipped.append(row)
|
||||
continue
|
||||
if row.currency != "AUD":
|
||||
rep.skipped.append(row)
|
||||
continue
|
||||
|
||||
cands = [
|
||||
t for t in by_amount.get(round(row.cost, 2), [])
|
||||
if abs((t["transaction_date"] - row.when).days) <= args.window
|
||||
]
|
||||
if not cands:
|
||||
rep.unmatched.append(row)
|
||||
else:
|
||||
candidates.append((row, cands))
|
||||
|
||||
# Assign one-to-one, best pair first.
|
||||
#
|
||||
# Without this a ledger row can be claimed by several CSV rows. That is not
|
||||
# hypothetical: the NZ trip has two identical $10.16 Uber trips on one day
|
||||
# and three matching ledger rows, and four PayMyPark rows in the same shape.
|
||||
# Attaching a split twice is harmless (the unique key absorbs it) but it
|
||||
# leaves the second CSV row silently unrepresented while looking matched,
|
||||
# which is a lie in the report rather than a defect in the data.
|
||||
scored = sorted(
|
||||
((score(row, t), row, t) for row, cands in candidates for t in cands),
|
||||
key=lambda x: -x[0],
|
||||
)
|
||||
taken_tx: set[int] = set()
|
||||
taken_row: set[int] = set()
|
||||
for s, row, t in scored:
|
||||
if id(row) in taken_row or t["id"] in taken_tx:
|
||||
continue
|
||||
taken_row.add(id(row))
|
||||
taken_tx.add(t["id"])
|
||||
rep.matched.append((row, t))
|
||||
for row, cands in candidates:
|
||||
if id(row) not in taken_row:
|
||||
rep.ambiguous.append((row, cands))
|
||||
|
||||
total = len(rep.rows)
|
||||
considered = total - len(rep.skipped)
|
||||
print(f"CSV rows {total}")
|
||||
print(f" skipped {len(rep.skipped)} (settlements, zero-cost, unshared, non-AUD)")
|
||||
print(f" considered {considered}")
|
||||
print(f" matched {len(rep.matched)} ({len(rep.matched)/max(considered,1)*100:.1f}%)")
|
||||
print(f" ambiguous {len(rep.ambiguous)}")
|
||||
print(f" unmatched {len(rep.unmatched)}")
|
||||
print()
|
||||
print("Row modes:")
|
||||
for m, n in modes.most_common():
|
||||
print(f" {m:<18} {n}")
|
||||
|
||||
already = sum(1 for _, t in rep.matched if t["has_split"])
|
||||
print()
|
||||
print(f"Of the matched, {already} already carry a split and would be left alone;")
|
||||
print(f"{len(rep.matched) - already} would gain one.")
|
||||
|
||||
if args.write:
|
||||
# Imported as settled: these obligations were discharged on a platform
|
||||
# we no longer run, and the residual is already carried by transaction
|
||||
# 2348. Writing them unsettled would re-open ~$40k of debts that were
|
||||
# paid years ago. See ACTIVE_OBLIGATION in analytics-sql.ts -- settled
|
||||
# rows stay in spend and leave every owed figure, which is exactly the
|
||||
# point: this import exists to correct historical SPEND.
|
||||
SETTLED_ON = "2026-01-09" # the carryover's date
|
||||
written = 0
|
||||
for row, tx in rep.matched:
|
||||
if tx["has_split"]:
|
||||
continue
|
||||
payer_share = round(100 - row.ower_share, 2)
|
||||
pairs = [(row.ower, row.ower_share)]
|
||||
if payer_share > 0:
|
||||
pairs.append((row.payer, payer_share))
|
||||
for pid, share in pairs:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO transaction_splits
|
||||
(transaction_id, participant_id, share_percent, settled, settled_at)
|
||||
VALUES (%s, %s, %s, true, %s)
|
||||
ON CONFLICT (transaction_id, participant_id) DO NOTHING
|
||||
""",
|
||||
(tx["id"], pid, share, SETTLED_ON),
|
||||
)
|
||||
written += cur.rowcount
|
||||
conn.commit()
|
||||
print(f"\nWROTE {written} split rows (settled=true, settled_at={SETTLED_ON}).")
|
||||
|
||||
if args.verbose:
|
||||
print("\n--- ambiguous ---")
|
||||
for row, cands in rep.ambiguous[:40]:
|
||||
print(f" {row}")
|
||||
for t in cands[:3]:
|
||||
print(f" -> #{t['id']} {t['transaction_date']} {t['description'][:44]!r}")
|
||||
print("\n--- unmatched ---")
|
||||
for row in rep.unmatched[:60]:
|
||||
print(f" {row}")
|
||||
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,56 +0,0 @@
|
||||
[
|
||||
{
|
||||
"file": "dd-credits-zero.html",
|
||||
"id": "19b692bdf1ea7a08",
|
||||
"subject": "Order Confirmation for Siddharth from Chilli India",
|
||||
"receivedAt": "2025-12-29T08:14:00.000Z",
|
||||
"sender": "DoorDash <no-reply@doordash.com>",
|
||||
"why": "DoorDash credits-funded, Total Charged $0.00",
|
||||
"stated": null
|
||||
},
|
||||
{
|
||||
"file": "ue-aud-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhmyVi0AAAA",
|
||||
"subject": "Your Friday evening order with Uber Eats",
|
||||
"receivedAt": "2025-10-17T09:50:39Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total A\\$",
|
||||
"stated": "Total A$54.87"
|
||||
},
|
||||
{
|
||||
"file": "ue-nzd-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf4nGLUAAAA",
|
||||
"subject": "Your Saturday evening order with Uber Eats",
|
||||
"receivedAt": "2025-05-03T19:26:05Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total NZ\\$",
|
||||
"stated": "Total NZ$22.83"
|
||||
},
|
||||
{
|
||||
"file": "ut-aud-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhLnaIfAAAA",
|
||||
"subject": "Your Saturday morning trip with Uber",
|
||||
"receivedAt": "2025-09-05T23:17:58Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total A\\$",
|
||||
"stated": "Total A$14.19"
|
||||
},
|
||||
{
|
||||
"file": "ut-nzd-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf1poEUAAAA",
|
||||
"subject": "Your Sunday afternoon trip with Uber",
|
||||
"receivedAt": "2025-04-27T13:27:16Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total NZ\\$",
|
||||
"stated": "Total NZ$10.83"
|
||||
},
|
||||
{
|
||||
"file": "ut-inr-symbol.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAgPBHtAAAAA",
|
||||
"subject": "Your Friday evening trip with Uber",
|
||||
"receivedAt": "2025-06-06T18:00:51Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total \u20b9",
|
||||
"stated": "Total \u20b9622.74"
|
||||
}
|
||||
]
|
||||
@@ -1,549 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head><!--[if gte mso 9]><xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
<title>DoorDash</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<style type="text/css">body {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%!important;
|
||||
-ms-text-size-adjust: 100%!important;
|
||||
-webkit-font-smoothing: antialiased!important;
|
||||
}
|
||||
img {
|
||||
border: 0!important;
|
||||
outline: none!important;
|
||||
}
|
||||
p {
|
||||
Margin: 0px!important;
|
||||
Padding: 0px!important;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0px;
|
||||
mso-table-rspace: 0px;
|
||||
}
|
||||
td, a, span {
|
||||
border-collapse: collapse;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
.ExternalClass * {
|
||||
line-height: 100%;
|
||||
}
|
||||
.em_defaultlink a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[x-apple-data-detectors], u+.em_body a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media only screen and (max-width:667px) {
|
||||
.em_main_table {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_wrapper {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_hide {
|
||||
display: none!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto !important;
|
||||
}
|
||||
.em_full_img img {
|
||||
width: 100%!important;
|
||||
height: auto!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding-right: 10px!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 40px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h10 {
|
||||
height: 10px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h30 {
|
||||
height: 30px!important;
|
||||
}
|
||||
u+.em_body .em_full_wrap {
|
||||
width: 100%!important;
|
||||
width: 100vw!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 190px !important;
|
||||
height: 40px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 17px !important;
|
||||
line-height: 40px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 140px !important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 40px!important;
|
||||
line-height: 44px!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding: 0px 15px !important;
|
||||
}
|
||||
.en_icon {
|
||||
width: 30px !important;
|
||||
padding-bottom:10px !important;
|
||||
}
|
||||
.em_rounded {
|
||||
border-top-left-radius: 25px !important;
|
||||
border-top-right-radius: 25px !important;
|
||||
}
|
||||
.em_bold {
|
||||
letter-spacing: -1px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:480px) {
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.ft_16 {
|
||||
font-size: 14px!important;
|
||||
line-height: 18px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 35px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 165px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 105px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:374px) {
|
||||
.ft_16 {
|
||||
font-size: 12px!important;
|
||||
line-height: 16px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 20px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 30px!important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 160px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 95px !important;
|
||||
}
|
||||
}
|
||||
@media screen {
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Regular';
|
||||
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Medium';
|
||||
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Bold';
|
||||
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-ExtraBold';
|
||||
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!---->
|
||||
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||
</tr> <!-- banner Section -->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" style="width:60px;" width="60"></td>
|
||||
<td align="center" class="em_hauto" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEkIYH_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1Jd3aVxMduJiqTN-2FrgtJfvcZfTHhj5HcBZymhPVxScgd1TCe4E2-2FYMFqXzB9Or-2FAP5yIgF2yNJhy8OA3CMlV8LdA8Rd-2Fsxt1caI8cPn9hyYypY-2FLckUNAxRzwuBCAQ1buubQlldewgQiuToSUsejOOxk-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>2:00 pm - 2:15 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3D8BvI_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaOMC6-2F6zNNGOii9obOCSUz236yIB4gCzgF-2BEE7DFN-2BzWJDOZETyK2c-2BdBr5QJMquOChmyoid171OpGSki94P-2Br-2FT5mkOXMUdSuXDA9B2zx9bfVlZsW1KYw6NBNiJ3c-2F9p-2FTFh6SVp-2Ferm15pDsGPoc-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width:20px;" width="20"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!--Illustration 1-->
|
||||
<tr>
|
||||
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEGC0g_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaZrmPxM5tO3MVqwpeuZtxlipSYowDluqjEhtng6OZ1tiReEtrMZSXfyizE8Z-2BoYl-2FOhh2mVbKh8Ag8sj-2FY0Dr5fXcralZiuXdtrszdPSlbYpbbdCWJgW2Mvx4JTup-2Be7s2F323Q0hdUfWlDu1kXlV0-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/2f9c7fde7cfed840/original/-template-OrderConfirmation-foodbag.png"></a></td>
|
||||
</tr> <!--//Illustration 1--><!----><!-- //banner Section -->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:6%;" width="6%"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with credits<br> Mad Mex</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $14.64</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||
<tbody>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Burrito</b> (Mains)<br><font color="dimgrey">• Slow Cooked Beef (GF)</font><br><font color="dimgrey">• Fresh Guacamole (GF, VG)</font><br><font color="dimgrey">• Spicy Salsa</font><br><font color="dimgrey">• No Beans (GF,V)</font><br><br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$22.10</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$22.10</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!---->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$1.99</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$24.09</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
|
||||
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$14.64</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!----><!----><!----><!----><!----><!----> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3DZM8O_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaVyVX-2BN0rfrZb4gqsyh-2FPhwDCNS7-2Fr19T2GWsMfiRQ3UNw3buP-2FdHaE5XAo30Zx73XG0x2-2Bj9CkYTdbTkYtPKndOo8C-2Frr-2BJqyRFJZMfQUP1ZV-2F2Ey3WNBzbwVr6SxafeujsUnxXrbM6VJ3DMYac3E-3D" universal="true">Get Order Help</a></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td style="width:6%;" width="6%"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYCzx-2Br1lrYaof6JiBiFLOVfEOgDjXh7OOs7yReUnPIfAQpor-2FGsJy8J81hADh-2FLb3M-3Dcz1D_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JRYFdI4rWGqZbwcwPjA-2FfGSpVNEJe6-2BhKqFbWrIPgphG2UaqzMu-2FrPVVEEnOy-2F7K7l-2Fiy9DJuVosOT1N6tV6enGQ9g8qH2bVpDkJgmaqxnNTS4xf9s7LwM3H-2FEquZm-2By-2FmUkLGnDPlpUJFzu1om-2F4mY-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYC9Fbd7OtaSznXr4XXA8cVP-2BJHCjKWkCwgvETCnlfYzgA-3D-3D9VgN_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JePrOHq5c5aW6evBo1DEdQJNXu-2BX-2FLP1H7ZXkSsghQ0EUu5Q6HYBtkMij3hyHvfxKdfDvfPKun4porDdzU27HkbjUwffVpDDk5SZimtlGs-2B8re-2FwuFPMI9TFYPc8JMXjGAWJTpoqf-2BcFT1RgPBXiW4Y-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B2-2FBNoONAVXEROMy-2FebOh-2BiIcflH-2B33fKp22eXYo6U8vHb6sK-2FhKm1eTZq8fP2ZliSW-2FDJ4KPJHZKnoxb-2FEyRvVNj8NRt3Z7VhrxSD83L-2BD-2Fg-2BYQJkcfBPsJzteuA7jtfPftcKLG1twN4g1gQQZHw9OGtsD1rLq9iXJ6ZzaVdWLEzmjDcZ7g3fTMixrMzH4oIIOEair3v0qOMD-2BV-2BHU7ncYtO-2FnD0bk1O9XI94PBy9e9JXZDgi2OxIT84h7X7b83MwQfHv6Y0eFjKVrvu0wzSlch7hpGzIv6v9RQGcCePKn-2BQ-2B85mf3KcKtUiyQ-2Bcvh-2BNe6ZHrHZAucV3pF0Fdo4Yn1-2Fie3uN1peu4VputGOf9gYSRqzfDIxJ-2BpD9KcAQghrRgZ08n7rFpdtIeHrI0J-2BsvQ0YqnprMwzKBgarmyWY4Ll6vo-2B-2FQLHQVXWlGWqomUtaBIY1wIPtH3sUn48aYCsuzz9tPifFXGPqgruVpD2mpi3C41MG8rFgUFQwfp4xV7SLCp8axKXY3t7WsYtRFw04WcnX-2FJMSRUrn8bq8PbEHq3Hjikf10MnkiyW7GobtuKhHdBL4F6ntalNVUHk3neSbhFBHGlJJQUA2Kfxt1bsI5eAQoX-2FRsU2BYtChgxaMUD53E-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
@@ -1,817 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
|
||||
<style type="text/css">
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
|
||||
</style> <!--<![endif]--> <!-- STYLE RESETS -->
|
||||
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
|
||||
</style> <!-- BACKGROUND COLORS -->
|
||||
<style type="text/css">
|
||||
body,#MainTable{background-color:#F4F4F4;}
|
||||
#Basic000{background-color:#FEFFFF;}
|
||||
#Sky200{background-color:#DDF4F4;}
|
||||
</style> <!-- FONT STYLES -->
|
||||
<style type="text/css">
|
||||
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
|
||||
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
|
||||
ul,ol{padding:0 0 0 20px;}
|
||||
li{font-weight:450;margin:0 0 8px 0;}
|
||||
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
|
||||
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
|
||||
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
|
||||
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
|
||||
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
|
||||
h6{font-size:16px;line-height:18px;}
|
||||
p.p1{font-size:20px;line-height:26px;}
|
||||
p.p2{font-size:16px;line-height:22px;}
|
||||
p.p4{font-size:12px;line-height:14px;}
|
||||
sup{font-size:11px;line-height:11px;}
|
||||
</style> <!-- FONT COLORS -->
|
||||
<style type="text/css">
|
||||
#MainTable table td{color:#191919;}
|
||||
#MainTable table td a{color:inherit;}
|
||||
#MainTable table td p a{text-decoration:underline;}
|
||||
.Red200{color:#EB1700 !important;}
|
||||
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:699px){
|
||||
#MainTable > table {max-width:410px!important;}
|
||||
.full{width:100%!important;height:auto!important;}
|
||||
.pad0{padding-left:0!important;padding-right:0!important;}
|
||||
.pad8{padding-left:8px!important;padding-right:8px!important;}
|
||||
.pad24{padding-left:24px!important;padding-right:24px!important;}
|
||||
.logo{padding-top:40px!important;padding-bottom:40px!important;}
|
||||
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
|
||||
h2{font-size:32px!important;line-height:32px!important;}
|
||||
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
|
||||
}
|
||||
</style> <!-- DARK MODE STYLES -->
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme:dark){
|
||||
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
|
||||
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
|
||||
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
|
||||
#MainTable table td{color:#FFFFFF!important;}
|
||||
#MainTable #Footer table td a{color:#FFFFFF!important;}
|
||||
#MainTable .Red200{color:#FF3008!important;}
|
||||
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
|
||||
#MainTable .grayCopy p{color:#A6A6A6!important;}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
:root{color-scheme:light dark;supported-color-schemes:light dark;}
|
||||
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
|
||||
<style type="text/css">
|
||||
#MainTable td a{color:#191919;}
|
||||
ol,ul{margin-left:20px !important;}
|
||||
li{text-indent:-1em;}
|
||||
</style>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="body" style="width:100%;margin:0;padding:0;">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
|
||||
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
|
||||
<div style="display: none; max-height: 0px; overflow: hidden;">
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
</div>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="pad24" style="padding: 0 64px;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEerk2_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsLgM1km0RQLa4WV7Y99oaIhdT35ahysXpZwozejK1qEgOqrm8WZjgms1TsVagTaVDuAieGpWb-2FQlcU9t3Vh8TCZEmOz2Loi3CvHDdReh6E5oaG4UkH-2BbB80dFEw5AwJF-2B0-3D" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">There are adjustments to your order.</h1></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="pad8" style="padding: 0 40px 40px;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="pad24" style="padding:40px 40px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;"><!----> <p class="p2" style="margin:0 0 4px 0;">Paid with credits</p> <p class="p2" style="margin:0 0 4px 0;">ALDI</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $0.00</strong></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 16px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><h4>Items that were adjusted</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"><!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Out of Stock</span></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:#767676;"><strong>5x</strong> Coca-Cola Coke Zero Sugar Soft Drink (1.5 L)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color:#767676;white-space:nowrap;"> $16.45 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
|
||||
</tr> <!-- --> <!-- --> <!-- -->
|
||||
<tr>
|
||||
<td align="left"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>2x</strong> Specially Selected Beef Wagyu Burger (150 g)</p></td>
|
||||
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;"> $8.58 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Ready, Set...Cook! Wagyu Beef Burgers (400 g)</p></td>
|
||||
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$9.39</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
|
||||
</tr> <!-- --> <!-- --> <!-- -->
|
||||
<tr>
|
||||
<td align="left"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns with Sesame Seeds (200 g)</p></td>
|
||||
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;"> $3.89 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns (200 g)</p></td>
|
||||
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$3.89</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><h4>Items you ordered</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ironbark Pork Belly Pack</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $24.21 </p></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$18.99/kg • Purchased 1.275 kg</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum Loose (each) (each)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.54 </p></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$5.99/kg • Purchased 0.257 kg</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Broccoli Loose (each)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.08 </p></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$4.49/kg • Purchased 0.685 kg</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ginger Loose (each)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.47 </p></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$29.99/kg • Purchased 0.149 kg</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Berg Streaky Bacon (200 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.69 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Emporium Selection Burrata (150 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $7.09 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Anco Soft Anco Soft Fabric Softener Concentrate - Cashmere Touch (1 L)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.99 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Pure Vita Canola Oil (2 L)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $6.49 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Jalapeno Chillies (80 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.49 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Farmdale Thickened Cream (300 ml)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.69 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Mandarins (1 kg)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.49 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Sweet Valley Fruit Salad in Syrup (825 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.89 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>3x</strong> Hass Avocado</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $5.37 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Spring Onion Bunch</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.69 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Coriander Bunch</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.99 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Subtotal</p></td>
|
||||
<td align="right" valign="bottom"><p class="p2">$94.45</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Bag Fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.75</p></td>
|
||||
</tr> <!----> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Tax</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Delivery fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Service fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$8.95</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Dasher tip</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Discount</p></td>
|
||||
<td align="right" valign="top"><p class="p2">-$25.00</p></td>
|
||||
</tr> <!----> <!----> <!----> <!---->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
|
||||
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$0.00</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:16px 0 24px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYUIJyXMOWcKUkQoS6w1cidTUBltD9gsdDsG54KhCqi0TpTxXlRftLSD-2F0zchmY726xMeqb-2BYjhquRL7EwEUa-2B-C1a_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIzcz91FpNoAfjDMq4Z1wpXHGTMPHfVoKcReXA-2FJ6jaKo-2B6xGAG-2Bp5h6Him0z2xNQVc2hNO8YatcJsnFfudME5OtlTe61-2Bq439PkYJf0JqaFj1iwRjZNlhThyTmY7ccTBU-3D" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrjJH9_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIjwoUCSEKvO5n-2Bj16PIPm9vylVUKXBAg-2Fy62EoW7wAeLOkv7ljadn2lJdO1Z8YyTZVsg50XPfub07LBUsUSDmpWsvorrlalMazpGr6pIsnVp6l5lDVPbZ82aaASmCqGBc-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMj2LIo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIrFQJW5Yztgt2Jh362zEdlWVE33xdgG5SV98hQgmujo0E4JvhCRaOchZGxbbbqba5s49iSoOH1uA1snY7iHXvSUNUHWVknLjLn6FaOweBoSS4nrQXEl0UQpbIiLKqwLeM-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3JdserrDEfSsXb7VYNi8XmPfj32D9Xcsg-2BKnH54BOn83XeKSYsMOiCB2O7NlyiRLKTOp-2FBccW4vEHOV-2FHaLf-2FAjylTtey6rU4sqCD635svGzB8KNIeJlLUHQHFm36Lriv1GKTHI0TRmME0InLq-2F8D2jWE2PvL5vl2AuTCSyofMbb8v6F0-2Fp2tKFfRS7SLHGABa-2BiIEO0Cj-2B51-2BCj8vI17Ej73lSLlLpOQjZQ5w6Xjpy94GXTJoyFOgOCkMe1dYoozgKu053kwmU-2FQgzvTvZvWljLEAh8AKZgpttD0qf9fwmzT-2FfbmEACAx1lYnUbMQSEnKVrq-2BN93H5GudoxrcNAH5xj7CeRBdn-2B-2Fm2uq78KkVh9j7d2poYBFM-2F0phg4yfzMJ8HenyMNPoDsxTYF2uwr9FPYHeHB92FdN-2FyMNtbhWu3f2Bj2Jp32hssVlE8ypjMbIy3F8Z4K8hxnzYgtXhQn9f7NJRAIR6ZxzgEYOW9gIQ1FDvzuTPT-2BOgXirIYJd7VNW1ls-2F33I09NUJXDzuQt6FpCbkzrw5rMFL9uAjGdsCzWAwAdtI0Vpj9QHVbFrQ7V0Km2o1xng4pDAf2zzOoVcOfy6Llv7NL5VR958SiCNPk-2FivSDJKGbqADXQIiUAHJEWxjJh9" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
@@ -1,592 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
|
||||
<style type="text/css">
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
|
||||
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
|
||||
</style> <!--<![endif]--> <!-- STYLE RESETS -->
|
||||
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
|
||||
</style> <!-- BACKGROUND COLORS -->
|
||||
<style type="text/css">
|
||||
body,#MainTable{background-color:#F4F4F4;}
|
||||
#Basic000{background-color:#FEFFFF;}
|
||||
#Sky200{background-color:#DDF4F4;}
|
||||
</style> <!-- FONT STYLES -->
|
||||
<style type="text/css">
|
||||
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
|
||||
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
|
||||
ul,ol{padding:0 0 0 20px;}
|
||||
li{font-weight:450;margin:0 0 8px 0;}
|
||||
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
|
||||
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
|
||||
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
|
||||
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
|
||||
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
|
||||
h6{font-size:16px;line-height:18px;}
|
||||
p.p1{font-size:20px;line-height:26px;}
|
||||
p.p2{font-size:16px;line-height:22px;}
|
||||
p.p4{font-size:12px;line-height:14px;}
|
||||
sup{font-size:11px;line-height:11px;}
|
||||
</style> <!-- FONT COLORS -->
|
||||
<style type="text/css">
|
||||
#MainTable table td{color:#191919;}
|
||||
#MainTable table td a{color:inherit;}
|
||||
#MainTable table td p a{text-decoration:underline;}
|
||||
.Red200{color:#EB1700 !important;}
|
||||
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:699px){
|
||||
#MainTable > table {max-width:410px!important;}
|
||||
.full{width:100%!important;height:auto!important;}
|
||||
.pad0{padding-left:0!important;padding-right:0!important;}
|
||||
.pad8{padding-left:8px!important;padding-right:8px!important;}
|
||||
.pad24{padding-left:24px!important;padding-right:24px!important;}
|
||||
.logo{padding-top:40px!important;padding-bottom:40px!important;}
|
||||
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
|
||||
h2{font-size:32px!important;line-height:32px!important;}
|
||||
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
|
||||
}
|
||||
</style> <!-- DARK MODE STYLES -->
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme:dark){
|
||||
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
|
||||
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
|
||||
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
|
||||
#MainTable table td{color:#FFFFFF!important;}
|
||||
#MainTable #Footer table td a{color:#FFFFFF!important;}
|
||||
#MainTable .Red200{color:#FF3008!important;}
|
||||
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
|
||||
#MainTable .grayCopy p{color:#A6A6A6!important;}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
:root{color-scheme:light dark;supported-color-schemes:light dark;}
|
||||
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
|
||||
<style type="text/css">
|
||||
#MainTable td a{color:#191919;}
|
||||
ol,ul{margin-left:20px !important;}
|
||||
li{text-indent:-1em;}
|
||||
</style>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="body" style="width:100%;margin:0;padding:0;">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
|
||||
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
|
||||
<div style="display: none; max-height: 0px; overflow: hidden;">
|
||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||
</div>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="pad24" style="padding: 0 64px;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEa1Qb_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4mfF5lxevUpoJQJ7UmMxollJfoLEGLjLhrl7sdZrHH5YHZ8O6My471AKWPRD9-2Fi1Gj5kDczANohQUl0hqEmYQa58AnEUM4SbTghHAzOx2-2FdIewmuvSy5w0lyALMIuqj5OPNSb0LQsZWNIEFVG8SDXp" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">Final receipt.</h1></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="pad8" style="padding: 0 40px 40px;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="pad24" style="padding:40px 40px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;"><!--
|
||||
|
||||
--> <p class="p2" style="margin:0 0 4px 0;">Paid with MasterCard Ending in 8032 and/or credits</p> <p class="p2" style="margin:0 0 4px 0;">Woolworths</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $60.93</strong></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><h4>Items you ordered</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Corned Beef Silverside (each)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $15.54 </p></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$11.50/kg • Purchased 1.351 kg</p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Jalapeno Chilli (1 ea)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.30 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Coca-Cola Zero Sugar Soft Drink Bottle (2 L)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $9.00 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Kewpie Sriracha Mayonnaise (300 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $6.11 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Angus Quarter Pound Beef Burgers (454 g × 4 pk)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $9.00 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum (1 ea)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.40 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Brioche Gourmet Sesame Brioche Burger Buns (4 pk)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $7.30 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Hass Avocado</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.10 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Helga's Light Rye Bread (680 g)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $5.95 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Red Capsicum (1 ea)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.40 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Garlic Head</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.15 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----> <!-- -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!-- --> <!-- -->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<!--
|
||||
|
||||
-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Lee Kum Kee Premium Dark Soy Sauce (250 ml)</p></td>
|
||||
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.65 </p></td>
|
||||
</tr> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --> <!-- -->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 32px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Subtotal</p></td>
|
||||
<td align="right" valign="bottom"><p class="p2">$67.90</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Bag Fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.50</p></td>
|
||||
</tr> <!----> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Tax</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Delivery fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Service fee</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$6.11</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Dasher tip</p></td>
|
||||
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||
</tr> <!----> <!---->
|
||||
<tr>
|
||||
<td align="left"><p class="p2">Discount</p></td>
|
||||
<td align="right" valign="top"><p class="p2">-$13.58</p></td>
|
||||
</tr> <!----> <!----> <!----> <!---->
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
|
||||
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$60.93</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:16px 0 24px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXhYhQ4ra2libvRVipt1sfv4E-2FKwlbgukGbMndo1ZsJhMUiuAxeGb86p06pmk6V-2FQsCkNG42P38jfRm6zgoux4YpHAP_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl40rFinK7NkUBQ-2Fl8tQtwvq8JTmf6gxu6dY07cNv4deM6rPu-2BQ-2FvHH4q00LtMFjDuuQ3oUFkbnbQHKhXkIUv3eSN4o7Kl6vrZHuHXulUntzFpaf0-2BlkucXK2YwHzC7HliOAyEF6-2FBa1FHUj7-2B2nt5St" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnr_qFX_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl47hiO5jAJKuIZrUFfimDnpntNrXHRtriBc5B6j34phPs9t-2FTsNtprBO5gereYjazskrJGz4kTq383Phk6ev5l-2FwkKCZAvVCUn589YJbi-2FoyqrxbStev5iE7PJ2Us6bPgQpdvcUQSEkpYDzIyI-2Blcws" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjesVR_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4Xvmy9Ho7hVCvflBaTaT7GIPK-2Bx-2FVY0eWPUYOIl9ze-2FigbiVg7zeZtEGGvUnrFsPyYMZvNqAmnc5awn0lwnuo6T2MwPVn7itLe2lk679GxtX4NcwYfkPFJXWYwEqreqmpCX0cU2u5EQKvkeFL5tyNJ" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3Jdse0BvY-2BpzLRd3B7D76PRNnOdtT8H1IzuhLRJrs8Ejc-2BQ4LU1ObPOGMj6kj4yuf9wvF-2BNU8yHnNyO0aMIUyf7Vw-2FvxkWQyIhy6jsNc64aO9QhWMEzgvfqcwerkGC0infSMLleveIQg1DJBFCa1l1sbtqm8v4JdVcD3tIswbkSwUBel2KQ6xwvitDjmSW4JqAVQjU2bnGfsVtQIUlNm-2FVkiKuTOkUSpSv-2BlmuRSRwJBxspIW-2FHL-2Fizzna0UNWeoxCQEouEyMXVglI9wvbdjxbE2fJhnbSO5BQgiIaJi6Z1Agc9UNZnRJQfmuKx-2BvkOjjEvTyMkvt-2F1GvutzvpeXgVGLKtJPwmIIEYfS4bk0NKmMQHCG0VBFaOwtyWNr32tHHB6clMeOlpiIJ9B-2FV6NK2NOKOn7nF2XPwImzV33xN-2B0bIZZeeDZf0piG4s3LMX9IoAqChWag-2BAHkcHaYx0ULMfGzIdlJCvGzkUFqfvXEkO7EloGLJ7Ir77VX8C-2BK1PF-2Bev7l0-2F1IBaXJUUw185DM9Yga0FDlihJgZGc0DBp8ml0RmuDjeqZPfpMHZgCXSm-2BXpoAOZg8Jjnj0I8Xr3SrabOHhH5IyYeOz-2FFmDwDDEXGh7pvKTOmsNosAQAHnmTCzcE1ABT" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
@@ -1,554 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head><!--[if gte mso 9]><xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
<title>DoorDash</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<style type="text/css">body {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%!important;
|
||||
-ms-text-size-adjust: 100%!important;
|
||||
-webkit-font-smoothing: antialiased!important;
|
||||
}
|
||||
img {
|
||||
border: 0!important;
|
||||
outline: none!important;
|
||||
}
|
||||
p {
|
||||
Margin: 0px!important;
|
||||
Padding: 0px!important;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0px;
|
||||
mso-table-rspace: 0px;
|
||||
}
|
||||
td, a, span {
|
||||
border-collapse: collapse;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
.ExternalClass * {
|
||||
line-height: 100%;
|
||||
}
|
||||
.em_defaultlink a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[x-apple-data-detectors], u+.em_body a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media only screen and (max-width:667px) {
|
||||
.em_main_table {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_wrapper {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_hide {
|
||||
display: none!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto !important;
|
||||
}
|
||||
.em_full_img img {
|
||||
width: 100%!important;
|
||||
height: auto!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding-right: 10px!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 40px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h10 {
|
||||
height: 10px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h30 {
|
||||
height: 30px!important;
|
||||
}
|
||||
u+.em_body .em_full_wrap {
|
||||
width: 100%!important;
|
||||
width: 100vw!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 190px !important;
|
||||
height: 40px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 17px !important;
|
||||
line-height: 40px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 140px !important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 40px!important;
|
||||
line-height: 44px!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding: 0px 15px !important;
|
||||
}
|
||||
.en_icon {
|
||||
width: 30px !important;
|
||||
padding-bottom:10px !important;
|
||||
}
|
||||
.em_rounded {
|
||||
border-top-left-radius: 25px !important;
|
||||
border-top-right-radius: 25px !important;
|
||||
}
|
||||
.em_bold {
|
||||
letter-spacing: -1px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:480px) {
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.ft_16 {
|
||||
font-size: 14px!important;
|
||||
line-height: 18px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 35px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 165px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 105px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:374px) {
|
||||
.ft_16 {
|
||||
font-size: 12px!important;
|
||||
line-height: 16px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 20px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 30px!important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 160px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 95px !important;
|
||||
}
|
||||
}
|
||||
@media screen {
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Regular';
|
||||
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Medium';
|
||||
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Bold';
|
||||
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-ExtraBold';
|
||||
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!---->
|
||||
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||
</tr> <!-- banner Section -->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" style="width:60px;" width="60"></td>
|
||||
<td align="center" class="em_hauto" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEYEgU_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8VVr86jIoxtluUxhWGTXIjaVcoCCZMHwuVy8TqEvwJR2SqSwWKk68wYn83-2BGVzp0Q96Il8ghSJk4VqSWMBaTqTd0KosKTV3a2OQBYPn6Cz3rrGJtTstNW0ohwZ27PImy8rSHU9BerWxzrGv35FfsQbw-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>12:56 pm - 1:06 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3Dvyw__gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8c11Xrs5mrwxYvXOjM-2B31YOhRV-2FVn8biClWfjAQE-2FFKhuO4Omylt1Br9cDjiGoEk5b7gncfkgZItoZ0uVSEVcQFQl4dKCCcWTsNznKpdwNr7hzrx4kK9eLK5xE8TC9mvmLuZcj8FiuGDz2TIKo2aVMk-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width:20px;" width="20"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!----><!--Illustration 2-->
|
||||
<tr>
|
||||
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEvLxo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8WRCD-2FqRcT3qYaTZhzIDIiyJa5PtObUnCjY4SczlOveif0uLWX8gst2iXvDBVuJze7WmSiLEzmN-2Bdnt8ToATmeJperQEu3d9WnvS8-2BknB4MEeK-2BwIQhdsw6X9lhJzohufq-2BYaSEg2vrnOSJed7mMlcQ-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/188a590491f6c3c9/original/-template-OrderConfirmation-dancingfood.png"></a></td>
|
||||
</tr> <!--//Illustration 2--><!----><!----><!-- //banner Section -->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:6%;" width="6%"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with MasterCard Ending in 8032<br> Subway</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $29.08</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||
<tbody>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Italian B.M.T.®</b> (All Subs)<br><font color="dimgrey">• Subway Footlong ®</font><br><font color="dimgrey">• Italian Herb & Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Old English Style Cheese</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Spinach</font><br><font color="dimgrey">• Tomato</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Jalapenos</font><br><font color="dimgrey">• Carrots</font><br><font color="dimgrey">• Honey Mustard Sauce</font><br><font color="dimgrey">• Sweet Onion Dressing</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$19.85</td>
|
||||
</tr>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Italian Meatball</b> (All Subs)<br><font color="dimgrey">• Subway 6-Inch ®</font><br><font color="dimgrey">• Italian Herb & Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Mozzarella</font><br><font color="dimgrey">• Double Meat (Selected Meat only)</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Pickles</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Ranch Dressing</font><br><font color="dimgrey">• Garlic Aioli</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$16.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$35.85</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!---->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$3.23</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
|
||||
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$26.02</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
|
||||
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$29.08</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!----><!----><!----><!----><!----><!----> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3D6wBj_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8bOKlnu7vSFB-2BIQ-2FSa0IlYx-2F9RNR0nsTokQzCNSqlpkomb9IWoemjq9we5J1OThMwqfb3jx6ATLRRe0hZPiX7urz31ND8Ku6OkcqBpF9xCGYZ-2B8b-2FruvvcHPSbTGjapxW5zZugpMbHsegIXXCMVBkNg-3D" universal="true">Get Order Help</a></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td style="width:6%;" width="6%"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrBpZV_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8Q8l21R-2BUnujFyqLh7dgRALyW1uXR-2FN8S0g00dvJLLfcmlzcQZxW44cVyDjeLcQdT7oXKMmfaQcqjpUqJASXPTpcyojzmAR7AJNx0XL1wVst-2FulVzDY1gZlfxA5V-2BU-2F-2B4tKqztf0gKzb-2BVk9xtu1bac-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjMbSD_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8aanhk3H7M0AmyPIeD-2FSytmK5nE5elBgAZcjX3JsIKz28vuZG4HM2bl-2FxI9FZWifPR-2FNBQ-2FOmTQqLo0YK3jcUZRxpOUh8EYeWnkDHAf9CUnJYhfBCXrPHy8-2BIouWsFADv6rViHCrwIlJq7PKIdjhsQU-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B0iC1V4tFBTMLc-2BJwfmofzVTZ93Zr38yBFeE9QeTx2HIPl8aahBdGSAhck5E0FopTVrs3ftPac-2BjBZQ-2F-2BOtO9AAatRdgybtYfdgGvULGP9PgScKi7bqhiEsMHtYK5BiFCtgHDd3wSmmSQEmLY5CxOdBaxC6hncHaAEfQICV5PV5RYoWJUn59AMV6iCj-2FAzsFmZDmbIos3SveyBNTIRxoQ9cPRYnGUJvcAO2C-2FN1QxbjuL3Y8ELK5gWiOUqwAeJaoIqwImrrWGzkrtF6Iu6lpAYw2yIml9u8FnfV-2BNo6dypylz5AbWbO4wm5AnTe6e7WmglLSPh-2FBr7RLUmgVl05JkvNjJmFrJUL-2FOA3VD8xCUTqdI8CiSkwhJgeP4PUUSKU6CPRXiEhpXbnj4JBARqbiZEvMCv3cChoIFQg20r6bMddokffgXp-2BiFWCvNIrp4WNUFzUegA1y-2FApKsMQemjXy5KM5DhVuyriQDcI1uKZbNH2L4o9QR7SSsEbRXbj-2F5-2FuRPshsLYfjHnGoPt11ACguPrpPaB5Kmke1gWd0UvceKwobel8MH3YRSgU2gvLJEJ000ZGfHr9Mj0j30BzHFnFaYfPzegN2Y2l5rXh6a3zR9Dsv0mUWU0mjRXrYJif2o5NEVM-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,868 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head><!--[if gte mso 9]><xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
<title>DoorDash</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<style type="text/css">body {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%!important;
|
||||
-ms-text-size-adjust: 100%!important;
|
||||
-webkit-font-smoothing: antialiased!important;
|
||||
}
|
||||
img {
|
||||
border: 0!important;
|
||||
outline: none!important;
|
||||
}
|
||||
p {
|
||||
Margin: 0px!important;
|
||||
Padding: 0px!important;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0px;
|
||||
mso-table-rspace: 0px;
|
||||
}
|
||||
td, a, span {
|
||||
border-collapse: collapse;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
.ExternalClass * {
|
||||
line-height: 100%;
|
||||
}
|
||||
.em_defaultlink a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[x-apple-data-detectors], u+.em_body a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media only screen and (max-width:667px) {
|
||||
.em_main_table {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_wrapper {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_hide {
|
||||
display: none!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto !important;
|
||||
}
|
||||
.em_full_img img {
|
||||
width: 100%!important;
|
||||
height: auto!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding-right: 10px!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 40px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h10 {
|
||||
height: 10px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h30 {
|
||||
height: 30px!important;
|
||||
}
|
||||
u+.em_body .em_full_wrap {
|
||||
width: 100%!important;
|
||||
width: 100vw!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 190px !important;
|
||||
height: 40px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 17px !important;
|
||||
line-height: 40px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 140px !important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 40px!important;
|
||||
line-height: 44px!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding: 0px 15px !important;
|
||||
}
|
||||
.en_icon {
|
||||
width: 30px !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
.em_rounded {
|
||||
border-top-left-radius: 25px !important;
|
||||
border-top-right-radius: 25px !important;
|
||||
}
|
||||
.em_bold {
|
||||
letter-spacing: -1px !important;
|
||||
}
|
||||
.em_side_15 {
|
||||
width: 15px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 20px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_ptop {
|
||||
padding-top: 20px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:480px) {
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.ft_16 {
|
||||
font-size: 14px!important;
|
||||
line-height: 18px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 35px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 165px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 105px !important;
|
||||
}
|
||||
.em_font_20 {
|
||||
font-size: 22px!important;
|
||||
line-height: 26px!important;
|
||||
}
|
||||
.em_f_16 {
|
||||
font-size: 16px!important;
|
||||
line-height: 20px!important;
|
||||
}
|
||||
.em_img img {
|
||||
width: 22px !important;
|
||||
height: auto !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.em_cta1 {
|
||||
width: 200px!important;
|
||||
height: 45px!important;
|
||||
}
|
||||
.em_cta1 a {
|
||||
font-size: 16px!important;
|
||||
line-height: 45px!important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:374px) {
|
||||
.ft_16 {
|
||||
font-size: 12px!important;
|
||||
line-height: 16px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 20px!important;
|
||||
}
|
||||
.em_img img {
|
||||
width: 20px !important;
|
||||
height: auto !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.em_f_16 {
|
||||
font-size: 15px!important;
|
||||
line-height: 19px!important;
|
||||
}
|
||||
.em_font_20 {
|
||||
font-size: 20px!important;
|
||||
line-height: 24px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 30px!important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 160px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta1 {
|
||||
width: 180px!important;
|
||||
height: 42px!important;
|
||||
}
|
||||
.em_cta1 a {
|
||||
font-size: 15px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 95px !important;
|
||||
}
|
||||
}
|
||||
@media screen {
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Regular';
|
||||
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Medium';
|
||||
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Bold';
|
||||
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-ExtraBold';
|
||||
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="em_body" style="margin:0px auto; padding:0px;" bgcolor="#ffffff">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="em_full_wrap" bgcolor="#ffffff">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" width="700" border="0" cellspacing="0" cellpadding="0" class="em_main_table" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!----><!-- --><!-- --><!-- --><!-- -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center" bgcolor="#D1EDEE">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||
</tr> <!-- banner Section -->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_hauto" valign="top" align="center" bgcolor="#D1EDEE" background="https://assets.doordash.team/m/1a24eac71707251f/original/-05_May-Order_Confirmation_Revamp-bg_img_02fr.jpg" style="background-position: right bottom; background-size: cover; background-repeat: no-repeat; height: 526px;" height="526"><!--[if gte mso 9]>
|
||||
<v:image xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style=" border: 0;display: inline-block; width:700px;height:526px;" src="https://assets.doordash.team/m/1a24eac71707251f/original/-05_May-Order_Confirmation_Revamp-bg_img_02fr.jpg" />
|
||||
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style=" border: 0;display: inline-block;position: absolute; width:700px;height:526px;">
|
||||
<v:fill opacity="0%" color="#ededed" />
|
||||
<v:textbox inset="0,0,0,0">
|
||||
<!----><!----><!----><!----><!---->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" width="60" style="width:60px;"></td>
|
||||
<td class="em_hauto" align="center" valign="top">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a target="_blank" style="text-decoration:none;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAErzBY_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9U6tAttMWs-2F17L53RCWUgjdFZL-2BBM-2FvMAbWQw8VEkpCEspZjWjjzE0oiyQq85glyUiycwLDkDUnlXnoi-2Ff89LkDx-2Bc-2BDpwSECL30G4Nt-2FixG9XD5XY21LkSdp3wDhHwzT5424p65s8zLSQqDqMSr-2FJg-3D-3D" universal="true"><img width="45" alt="DOORDASH" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" border="0" class="en_icon" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_font_58 em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="66" style="height:66px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table width="220" border="0" cellspacing="0" cellpadding="0" align="left" style="width:220px; max-width:220px;" class="em_cta">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_cta em_bold" align="center" valign="middle" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; "><a target="_blank" style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DXis9_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9DhoSoVJQPXHb0daBH8Su71-2FWDBw7ES1ME-2FUoDwtJ1oW0yC1YYQRsoWgxjV-2BgzJbsIISc2svF3BIflrR-2F4JaCzYXaSuX0Iv-2BBnV5dxK3NGiCpg70gOKqGPmQgE4kTqX7GZZVXQUInMiK7lB-2BeczLXaA-3D-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h90" height="28" style="height:28px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="20" style="width:20px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]>
|
||||
</v:textbox>
|
||||
</v:rect>
|
||||
</v:image>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- //banner Section -->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="6%" style="width:6%;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;">Paid with credits<br> Chilli India</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;">Total: $0.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;">Your receipt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;">2/210 Ballan Rd, Wyndham Vale VIC 3024, Australia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||
<tbody>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Chicken Lollipops</b> (NON-VEGETARIAN STARTERS)<br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$23.88</td>
|
||||
</tr>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">2x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Hyderabadi Goat Dum Biryani</b> (BIRYANI'S)<br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$47.98</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" bgcolor="#ffffff" class="em_pad1">
|
||||
<table width="530" style="width:530px;" border="0" cellspacing="0" cellpadding="0" align="center" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Subtotal</td> <!---->
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$71.86</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Taxes</td> <!---->
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!---->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Delivery Fee</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Service Fee</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --><!---->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Discounts</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">-$71.86</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" bgcolor="#ffffff" class="em_pad1">
|
||||
<table width="530" style="width:530px;" border="0" cellspacing="0" cellpadding="0" align="center" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;">Total Charged</td> <!---->
|
||||
<td class="em_defaultlink em_bold" align="right" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!----><!----><!----><!----> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzf-2F8itKxlwlpgC5eDvfobsPqcWIfajKSF9K5IVNYF4z5cDdr_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9yGet7aCJhkLcWM9jtE5P15Gw2aS9-2Bd8S0lxqE0ofFQUprfLXvCW-2Fyw6lcqXZj16oIJGN1Z3kyxulmmMYsrht81Fbmp7xhgSLYmCFSjIeEUapNr9xy5EHJGf5QgpKFBoWt24n8NDLxIVQHt7hhCz72g-3D-3D" universal="true">Get Order Help</a></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td width="6%" style="width:6%;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" bgcolor="#00838a">
|
||||
<table width="700" border="0" cellspacing="0" cellpadding="0" align="center" style="width:700px;" bgcolor="#00838a" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="73" style="height:73px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table width="700" border="0" cellspacing="0" cellpadding="0" align="center" style="width:700px;" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table width="275" border="0" cellspacing="0" cellpadding="0" align="left" style="width:275px;" dir="ltr" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a target="_blank" style="text-decoration: none;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DINmM_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ab-2BWHgIPHw-2FHA-2F8LmdECgPJuMjpcjZqmvbOeE-2FjweAF697cyRBN1bGlXezke3iQVhJlWoqTPIP6wOH-2B4pWw-2FSZzecFDoRexCypWWX3P9hHvZQo7rZEU7tsxj5JgFvOgtMytra1OA69PgnlD7sYqwLg-3D-3D" universal="true"><img width="275" border="0" alt="DOORDASH | Pick up your order | Ready now | I Picked Up My Order" style="font-family:Arial,sans-serif;font-size:16px;line-height:20px;color:#ffffff;display:block;max-width:275px;" src="https://assets.doordash.team/m/21479b41d7738a14/original/-08_August-Pickup_Order_Confirmation_Banner_Add-imgpsh_fullsize_anim.jpg"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]></td><td valign="top"><![endif]-->
|
||||
<table width="425" border="0" cellspacing="0" cellpadding="0" align="right" style="width:425px;" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" class="em_ptop">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side_15" width="20" style="width:20px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_font_20" align="left" valign="top" style="font-family:'TTNorms-Bold',Arial,sans-serif;color:#ffffff;font-size:24px;line-height:27px;font-weight:bold;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DP1G7_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9TY-2FNuh-2BDY9H-2BggfcF9DNm8tS-2F0Or82eJo3NB1P4i45n8bhW7X6ws7ulGWM6bg3oQzJvOsdVijjWDx4dcCUeJGbF-2B0Ym3Jb-2BHgIZXtdvXT3n2YQ06merlibFALmewVfDnhuod0QtRNmiXw-2FFVUDN9Ng-3D-3D" universal="true">What to do when you arrive</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="30" style="height:30px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D7lI6_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ZzL5VDXQ75SCksv9T-2BBr2RypmGvKuusULq0tKEaH4uE4vzXMns5EJCzWV6TYk-2FjXjSFiR6SpLmekxAokzPq1w4yx6ZFdXk4Dobwp054H1RQbz6TdnF7tabjp8IrSXgVr9WY7iNtPL92D9OBckgsOpA-3D-3D" universal="true"><img width="25" alt="1" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/7e33ff26ef721acd/original/-06_June-Pickup_Ready_Trigger_Transactional-img3.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D7Yxi_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mJLiBoDz0shrpBZ9UyY35gO2Tvb1mkMlDjb1L4hP-2Fe8w1mhlj7Vq-2BgkxFJshnRkjNvwHS6KydeE1U6kxaxP-2FgBg0SHCyHHPyCHpGC1WUI1-2BvIBH45EESdEbgRRstSmiMJnlSZBODcuvw6-2B-2FMmUFcNg-3D-3D" universal="true">Skip the line! Go straight to the restaurant’s pick-up counter.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DQVMA_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9TT29R06OuljbEo39-2FOTjWKbFYuihjiQbzN93K0WenMsdSv3CMjQ3LgXlB8Z23eVnUTgVUn-2FexjpNLPsk-2F4F8eQUAmmd-2FroRbrb-2B-2FHRZ4PM81L9BwR4dUhMMytvUHgXMeMaVQ-2BvlEXL-2B4-2BA-2F6NKiDHw-3D-3D" universal="true"><img width="25" alt="2" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/6e7053e55643679c/original/-06_June-Pickup_Ready_Trigger_Transactional-img4.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DhA1g_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mzX65ESzRyRG6-2FahXssBqsMHZ1Pb-2B4JH0zke3ALCl5ba1asn0pPJcsHIF21G1gxaXIDB1BTSmsTeeAP2vv2l9m-2B-2Be0B820AC82EmsiVAu-2BAXT5BUOLtE5KSO6nb0Tu8lymdaakTiTguPn9SK02pMfw-3D-3D" universal="true">Show the staff your DoorDash app or receipt to claim your order.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D0mF__gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh94MnLK9HomwO-2FOszt9E7QJHHky1AOyVqjlUKLIboeAL6IP3yZz1z7H74tRU5A-2FfyfbWbF1523ckL0gqpecu-2Bicoc7KBKQr7IOGs9J72-2BrnJ8ScOZ17p3zx6y-2BSKj0LdG0Y3zo3RT9jUWy9uC41IvVug-3D-3D" universal="true"><img width="25" alt="3" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/4ac84ede5f4634dd/original/-06_June-Pickup_Ready_Trigger_Transactional-img5.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3Dd9zs_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9T2MYp7K6Sf6fSWsAKtcO-2FN-2BLKQVPx0NItCo6TS9Lbz9DVXwvRY01J3L-2BB0rcAe8LX34GOvEUDMoiaIp-2Fzc1jNSYPOGyfNBhgoTZHJZOge23hA-2FAM5zvUmMgQIklw4qg2o0sFVjjpxrorZZrSxn3vvA-3D-3D" universal="true">Before heading out, double check to confirm you’ve received all items.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="32" style="height:32px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table width="244" border="0" cellspacing="0" cellpadding="0" align="left" style="max-width:244px;width:244px;background-color:#f7f7f7;border-radius:25px;" class="em_cta1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_cta1" align="center" valign="middle" height="52" style="font-family:'TTNorms-Bold',Arial,sans-serif;color:#00838a;font-size:21px;font-weight:bold;"><a target="_blank" style="text-decoration:none;display:block;color:#00838a;line-height:52px;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D2r2h_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9BlqHmjcqtruwkpuxTPfGAFBqNGbOff2wsddx2rLTQfUNcIpX6rp-2Fi3hN0y91lSiN77BoHqgHjhwWBGPj2dqZReijcs9pT2mVBhbLSE-2B6gD7cK3oVswuoKjmdtE9pHt-2FGYeampPt5ChmBk8wrTekfpg-3D-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="82" style="height:82px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side_15" width="65" style="width:65px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!--
|
||||
|
||||
|
||||
--> <!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 48px 0 32px 0; border-bottom: 1px solid #E7E7E7;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAExxvL_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9qQUzDXwAPVf3usjWBBGhHRLmujJqcr5Rpr0PP44oD28hQuWJPYJrorj7eh2LRnirXsIBnEOnfFYtqzJkMGMEGNmEOgHh3wyCdzJ2n2Puv04jUFFdcFfzwf2DhbocZswmyBpEfcpIH-2FlFcdQy-2FhT-2BOw-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/60228540eee4df0c/original/DoorDash-Logo-Full-Red100.png" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; color: #FF3008; font-family:'TTNorms',system-ui,sans-serif; font-weight: bold; font-size: 18px; text-decoration: none; width: 183px;" width="183"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:16px 0 0 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 16px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 280px; border: 0; padding: 0; border-collapse: collapse; float: left;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2BzcjCYVO02E6L8GZdu019pLCdzrwjBbgDTooc-2Bf2hmV45f2EGDuiNzmiTMuRZPKlHjhcIixsxt6mci9emxQ30-2BVxGv0RnlPka-2FFd4fNutCfWP59qLn5b2LnYMFz9QVRs-2FSPJVeax0MxwhWejYUsJXRb6tR22CAvldJ8oQ3uaWNGfc2kv_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9R4wgtEbczXbCqxsOmwOuD8TRRqpB8mgi65GNM0ra-2BDIasAQnax2eVxI3R-2B-2FuRqaLHBmPzYkNY7qp1O6je5HGjjUVmC9twIfopxuQDZ6-2FKexPxWMVSM-2F8loKR-2Bqg4VGGsWxZnktEHCP49h2XEsQVyrw-3D-3D" target="_blank" style="color: #191919;"><img alt="Download the app" src="https://assets.doordash.team/m/500fea64c23bc120/original/icon-footer-mobileapp.png" width="55" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 55px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2BzcjCYVO02E6L8GZdu019pLCdzrwjBbgDTooc-2Bf2hmV45f2EGDuiNzmiTMuRZPKlHjhcIixsxt6mci9emxQ30-2BVxGv0RnlPka-2FFd4fNutCfWP59qLn5b2LnYMFz9QVRs-2FSPJVeax0MxwhWejYUsJXRb6tR22CAvldJ8oQ3uaWNGfq8tx_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9nCInppLEx0QjS9UIqmR9A6tK1rrX29VgRrPMFhXKvuK7oQPkDzgCCSJrLjL7o4NQWvDB1sSZqQ-2FSe1FEfAAfV3nF79K2drmxPzHnH-2BfZnDBqxXCk-2FJySmYh7qTnjrSmQQOjWq0ELD3XYA-2Ffl8KwTSw-3D-3D" target="_blank" style="color: #191919; text-decoration: none;">Download <br>the app</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td data-skip-shadow-validation="true" align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%"><!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXcTh9RnomDHl8hjluP2Kf-2BD5RGeDVqI9bmTUfMaMWMZA-3D-3D9OQ4_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9KiOVKcHwAKXzSDerRpI06sHCdXelptvmh5TX6eheG3hL-2BDJApljrdxxxsnF2yuJCcY59R18ThK4PytDo6b3-2FxMMs2AmhqUuk6lOquSQHZZ9xaFPJvK-2B2VyuvOy2K6Cq8pjVy05LWrU-2BDmDv0dWLLkQ-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="Shop Gift Cards" src="https://assets.doordash.team/m/2f055075aa667d75/original/icon-dd-giftcard.png" width="43" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 43px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXcTh9RnomDHl8hjluP2Kf-2BD5RGeDVqI9bmTUfMaMWMZA-3D-3DY5So_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9GV3TaqwrUSNs0VNNJqAxdBlgKOmAVgbASYjmJxklvK8fK57W2se0CiBUCWF5vIsxzo-2B0-2FWPkW4yMrhD1MZWbP2Ins6z-2FKAMOGJThI3gulXRLVoaHYzIz5CZWYLXMQuTGoICEKHKLxMF797snr-2FlEDg-3D-3D" target="_blank" style="color: #191919; text-decoration: none;" universal="true">Shop <br>Gift Cards</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]></td><td align="left"><![endif]-->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 280px; border: 0; padding: 0; border-collapse: collapse; float: left;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVT5CX2SpN45IR7hQUH3fjvHqA4t-2F4SC8-2BnSVnPlHEF7Q-3D-3DBrSO_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9z0KK6nQtSnhfb-2FUomS803meemWRHA4sQjByYkmPXACE0mylumcET2NUswGrd9benLF5r-2FPeUmoj3Lse1JLnNkeRzU1y-2B50hwAOrNPeBBwiD8LueTQRuRIJU9n9bIHMUL2vW-2F41pw0gRonBp1iZLPEQ-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="Refer and Earn Credit" src="https://assets.doordash.team/m/73675450875ebb57/original/icon-footer-refer.png" width="30" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 30px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVT5CX2SpN45IR7hQUH3fjvHqA4t-2F4SC8-2BnSVnPlHEF7Q-3D-3DQYAA_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9b8DqiT5Q5ujCYXW1jmCIIQr2jrhQiXxpUpJkZVq6-2FXE3xAAaIU9uNCa3Tst0CMi30Xmf6B6blHudQ41g-2Bdo-2F-2FEJaQOyAihNzLn1QjSXjCXm9wsvNG4WFRvHcpjaUB9ysdVzj58W2YmDqsrwUX-2BAamA-3D-3D" target="_blank" style="color: #191919; text-decoration: none;" universal="true">Refer and <br>Earn Credit</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUmEso6tSMMqb4wX3Q58ulh0RYYyPmHEEAnk-2BGx0Kt-2BeA-3D-3Dnczm_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9uKQbfTVabK-2BCeR1hvsRChiANq1hO-2B0sgHCiQ2TT5j-2FThmhWRJWIFB-2FqVuSp-2FMqtezLuyqCEjJCQtd2a380n0ZMCRlgPpuxIxADrzkVC1pyCVnzEURgTGA0zWXTzRFtJkheSwwpf-2BdoaO5DIriyKX2g-3D-3D" target="_blank" style="color: #191919;"><img alt="Deliver with DoorDash" src="https://assets.doordash.team/m/d3fd5564cd1d0f3/original/icon-footer-deliverdd.png" width="27" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 27px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUmEso6tSMMqb4wX3Q58ulh0RYYyPmHEEAnk-2BGx0Kt-2BeA-3D-3D2kBd_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9le2StZYD3ubMp1KkMbKkNUQoWO4K5WcuSFl-2FAzi28IA-2FJy-2BsG6J9DEMetOBvHewEWRbr4bovhkci5-2FtozPIGcm3J-2BqMc1HK7CIpSAJIDCObAMR4Q5g5WWg7fk-2BiHH7rjW4X3Te-2Ft2Y1UFFZYA6Eu9g-3D-3D" target="_blank" style="color: #191919; text-decoration: none;">Deliver with <br>DoorDash</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 16px 0;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2025 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Inc. <br>303 2nd Street, South Tower, Suite 800 <br>San Francisco, CA 94107</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAfdX67BmAmt0ufvV0DN-2BQcKLBxKTnrSNIWzubRh3E-2BfgEsS2yrPfbmkJcf80iMMHs-3DwEg6_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9h3Rn-2F7yUSjc-2FwVCqVcNmU8CXMadRKnLkc4c7YA9pFdgbp7NrgKsaXN-2BHGEi4WqxDUgyheueRw5y1AuaHsjK8WHj9Bt5Bom8lX-2FhZeE9Fj3XHsEbY4o3UeLMcHYf0NXcPVaFrGwukrORpM40XL8d6BQ-3D-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 16px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><!----> <a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FB3YMlmntmTqKXVA7huNFqC-2FY2hoh-2Fz5YPfVOcCa6quNkOp_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9OdczW9buEiXcf20eZjTw6j3J4DfvzhvSMCiSvWZwiDKXclLhN-2FIwB-2B7PGLYtvQ60V3Jr5QHNH8gJFbU-2Bz1k7P2vaHXNv3aCa97vS-2FNB7-2FPdCUjGx9V-2F2kW5cbmeoapqiZzw-2BuS7DT16VHd3sto3Eaw-3D-3D" target="_blank" style="color: #191919;"><img alt="fb" src="https://assets.doordash.team/m/5188bb1f5f04acaa/original/logo-facebook.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B4AUo6me50US-2FvcfePJUZTRhfLFumwrfkjz2KbYGBSYXzRff_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9sIbSZ5WBiRa4hCI-2B34oVAaYz2w9t6Dpx6XDZHG1iczvd6GtkYR4x4eU86rC0FX1Ud2VYsHAqw4Q5JXWIiZf3H8oX1DPHS-2BCiLKN2gwMqd1gGkcHdN-2FX94xN0E7YU7pI7vb82d-2BHY0LZGRmP3wSrPQQ-3D-3D" target="_blank" style="color: #191919;"><img alt="tw" src="https://assets.doordash.team/m/250ca598c483f1b8/original/logo-x.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 12px 0 0;"><!----> <a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B2DmXtXKS05Hn1cgbRhFddpiLHO0EWksR6pd5wFKsdGvQCGd_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ZzV7C3gLn62BVgulaM3ruZ6flQuhgjIPc1NKsAsFHFyrWmfULs5CePKbilEE0Qe73jOUvfNQUBPBk9vGBwIkf7xoJsNuuTs3yaXkRyJUDRxEVe6XFZ17E-2F0vekhq57UAEdx1IoMULbfAwi5OIUcQJQ-3D-3D" target="_blank" style="color: #191919;"><img alt="ig" src="https://assets.doordash.team/m/30ad9ef70caf3a55/original/logo-instagram.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B9Cc31vkufkxkjeQDdWBHi-2BEykKWTniWhBO2q7EZvDyOuAqc_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9wuaGJcQf0orrDSRaKoipbvQDaYa-2F47nRDf-2BwZRi-2Bn5jM1kHB5Q6cFntzO0XSQZhBjEzoXpkvqIfaaBv1-2FpxzBWFnB-2F-2BDuOm3iKIn45MZr57P5q0PDBMjssxnMRoNDScjLmJIuivQwjsX9jOuk1SMcg-3D-3D" target="_blank" style="color: #191919;"><img alt="blog" src="https://assets.doordash.team/m/4d258e2251ad126b/original/icon_medium.png" width="18" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 18px;"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjwdgg_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh959Xd7Y6Gud2X1ehoSf-2B6JFftx-2BaY5Mq4YxuhkM55n-2BkcU47okbJWk6U0blQD0LfYHq227JOJbCKmDYH7YjpQEWkr2EOi8Ib9oLB5Md7Giae-2B-2FhI2ZoWq9YJipl7-2FZ37LRnS8I5tmKbedGAiHdI2hdg-3D-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center" bgcolor="#f4f4f4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" width="55" style="width: 55px;"></td>
|
||||
<td valign="top" align="center" style="padding-top:30px;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: 'TTNorms-Regular', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 18px; color: #ababab; font-weight:400;font-style:italic;" align="left" valign="top">Prop 65 Restaurant <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowVNhC-2BKbbpJ-2FyS4l8ZzrqkvpAbpmh6xMGqJ3PxZACrgiw-3D-3DW9aM_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9-2BX92qlOdUUG5zpRoqMtp61TUZGhnlfUjf4Og4-2FnA7XVQxt39u1XghxMdeU3hIXIwRwwyZu19taIo0dOY04BW2IM7y6Mx8nbbC7OYNoIDu4xkgO-2BFZwj-2BXHg9RkRpYVV4MlfX8V6IsDT0GVEpEfgfCw-3D-3D">WARNING</a><br> <br> Prop 65 Alcohol <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowVkh-2FKcx9IdsYOYOX3bSSQXX2VU9kylc3JZnginfYpP8Fv58V2Gad0szmjBZwRM34k-3Dgpsi_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mvITrwEQj9x3VDARPEbXxE-2F-2Bku-2FgBEukw4-2FlFJug1YLEHPvpxieYuJgxS0Faiq-2B6DpzyS3s9P-2BhpgswwZogoBC6HPkvgNTQV-2FJnfghwCIrprRnT0sMQXmUS1SpDaQPeZc0Ukc3RwTTcq7Kq-2BVVcrng-3D-3D">WARNING</a><br> Drinking distilled spirits, beer, coolers, wine, and other alcoholic beverages may increase cancer risk, and, during pregnancy, can cause birth defects. For more information go to <a href="https://tracksg.doordash.com/ls/click?upn=u001.w8bmSeHXyA0fd7rAPHCC-2BveYEEDFpPbKqpH0908D-2FbFTn662MrnwWC94hremUVyWnadZ_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9R4aHBRRb0w2n2FCCFOYlx5z5AkE1SBYQ4UrFK5MnIcZwchKoiq4OgAxK2JI9YXzMnQgtsALyjfQmjeNbjSPupiwUSEcdZiaXYMI5yiGsQTPa0RJfavowHuHTjgibU3ACzfx4hlVCHQwD8lzLzrLM0A-3D-3D">www.P65Warnings.ca.gov/alcohol</a>.<br> <br> Prop 65 BPA <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowW6WbwCN57LjWD5UaEYOVHrJPpLgJl33D1dHGtpAMDr2w-3D-3DHmwR_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9Iemc3b-2FjbWd4-2F8WUYcyMZ0svv7YTBgClQkvSBCqOUtSXfuZ5-2Bg6lHR4W-2BXc7uxb7qMuyzGRpiJLm-2BACbcHm-2Bku-2FTC7lNYoLHy-2FYQRbNcm-2FOk3qSlHHVnzxm6RihOUBKkOHuSpC42MXuR2bWQI4odUw-3D-3D">WARNING</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="30" style="height:30px; line-height: 0px; font-size: 0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="180" style="width: 180px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" height="1" width="700" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" border="0" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItoY-2FjAhYjqDwj5repwbzWNEue2JKteT4DEg7QY2vIxKrLZJCbuXHYZIzbee8h9tIN-2FDXVgfLQD4cGXRG4pvU9txqqdu78N63JaPaY-2BDo4qOWLe6-2BqnVfGz4g-2Ba-2BajGOeDJRt49161pCqdpSqkANOD7uPpa-2FIsuThD8ZfI9T6VtFxjuGxrsPYp-2F2dfZ3k2vX3ldEfD-2BAXCWlv9p884-2F2SRyJZCjbRCptm2UzImQFAxyJ3JjD349MBw90of5ckPJkHLa2s8Pad24yQCbMOuM35kYIvL2F-2FBQ0ptFHzENChwdaRJrb6cBf-2Fn7zxgLCTbbfJjc5PYH6f03JNOD7t3NknVYEdYwx3buSysW6f2CnLZESh4zqNMaIRkPKYyOSjrjWYLnf73C4QA8-2FPwb3GS8GN0yT9WHX-2B-2Fte61MfQMplNw8cWBdOS46KJWZvpse8d0Z4dx5DCBHkyZIknmmkb-2BsCT0b3oWk-2B8GCKHQ7XZKQurRgp5DLtNovpC0Zt-2FDfIJYaJ00-2FgnfaSpwfDnDOJqwv49a5Dcb5IOuGknhwTag1y2B-2BMO6SRdaOa8Ws8-2Fvq9FRdtwzwDiTXIPmoFZFkHF2ExDW7FJI4YvTjC1RaOciyUTbaapJuI9uLzrvXEHAUix3hAWSTl-2B6AVVANasaQrx-2FLjSL0AwM8ccKHDEgdM5eZ8FhiVwA-3D-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "19f89664ea7b3aeb",
|
||||
"date": "Date: Wed, 22 Jul 2026 10:36:50 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Homey Meals",
|
||||
"len": 38394
|
||||
},
|
||||
{
|
||||
"id": "19f68fd09cd2c3f1",
|
||||
"date": "Date: Thu, 16 Jul 2026 03:34:00 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Mad Mex",
|
||||
"len": 37448
|
||||
},
|
||||
{
|
||||
"id": "19f656a298f42858",
|
||||
"date": "Date: Wed, 15 Jul 2026 10:54:42 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Carl's Jr.",
|
||||
"len": 37970
|
||||
},
|
||||
{
|
||||
"id": "19f605de9ab82216",
|
||||
"date": "Date: Tue, 14 Jul 2026 11:23:14 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Delhi Nights - Sweets & Indian\r\n Cuisine",
|
||||
"len": 38707
|
||||
},
|
||||
{
|
||||
"id": "19f2b6390a95b665",
|
||||
"date": "Date: Sat, 04 Jul 2026 04:29:32 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from TEG KEBABS & BIRYANI",
|
||||
"len": 38005
|
||||
},
|
||||
{
|
||||
"id": "19f2256c51714b9c",
|
||||
"date": "Date: Thu, 02 Jul 2026 10:18:58 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Sri Dwaraka",
|
||||
"len": 37817
|
||||
},
|
||||
{
|
||||
"id": "19f1d0b003834626",
|
||||
"date": "Date: Wed, 01 Jul 2026 09:38:07 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Taco Bell",
|
||||
"len": 38019
|
||||
},
|
||||
{
|
||||
"id": "19f1c301dda7332c",
|
||||
"date": "Date: Wed, 01 Jul 2026 05:39:01 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||
"len": 38753
|
||||
},
|
||||
{
|
||||
"id": "19f0c5624707ae34",
|
||||
"date": "Date: Sun, 28 Jun 2026 03:46:39 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
|
||||
"len": 68206
|
||||
},
|
||||
{
|
||||
"id": "19f0c0d0c33bf5c9",
|
||||
"date": "Date: Sun, 28 Jun 2026 02:26:49 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
|
||||
"len": 47419
|
||||
},
|
||||
{
|
||||
"id": "19eed89eee79b624",
|
||||
"date": "Date: Mon, 22 Jun 2026 04:14:59 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||
"len": 48653
|
||||
},
|
||||
{
|
||||
"id": "19eed498e3fb7fce",
|
||||
"date": "Date: Mon, 22 Jun 2026 03:04:39 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||
"len": 44104
|
||||
},
|
||||
{
|
||||
"id": "19eed38ca77047a1",
|
||||
"date": "Date: Mon, 22 Jun 2026 02:46:21 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Oporto",
|
||||
"len": 38675
|
||||
},
|
||||
{
|
||||
"id": "19ee9b9b1b7073c9",
|
||||
"date": "Date: Sun, 21 Jun 2026 10:28:39 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37871
|
||||
},
|
||||
{
|
||||
"id": "19e95be74e216594",
|
||||
"date": "Date: Fri, 05 Jun 2026 03:05:46 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37817
|
||||
},
|
||||
{
|
||||
"id": "19e854cdbf552dcf",
|
||||
"date": "Date: Mon, 01 Jun 2026 22:27:45 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from TOMKINS BAKERY",
|
||||
"len": 37741
|
||||
},
|
||||
{
|
||||
"id": "19e8020eb0027109",
|
||||
"date": "Date: Sun, 31 May 2026 22:21:39 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Tonmax Bakery",
|
||||
"len": 37439
|
||||
},
|
||||
{
|
||||
"id": "19e16b64b3f6dbad",
|
||||
"date": "Date: Mon, 11 May 2026 11:05:04 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||
"len": 39750
|
||||
},
|
||||
{
|
||||
"id": "19e16b1a2d9526f8",
|
||||
"date": "Date: Mon, 11 May 2026 11:00:00 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Burger Road",
|
||||
"len": 38230
|
||||
},
|
||||
{
|
||||
"id": "19e11390ea456222",
|
||||
"date": "Date: Sun, 10 May 2026 09:30:11 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37847
|
||||
},
|
||||
{
|
||||
"id": "19cdfa1d25f194a8",
|
||||
"date": "Date: Thu, 12 Mar 2026 01:20:48 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Kesari Indian Kitchen",
|
||||
"len": 37922
|
||||
},
|
||||
{
|
||||
"id": "19cc62db0460b8a0",
|
||||
"date": "Date: Sat, 07 Mar 2026 02:43:28 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37884
|
||||
},
|
||||
{
|
||||
"id": "19ca1f0fa493f0ca",
|
||||
"date": "Date: Sat, 28 Feb 2026 01:50:49 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37862
|
||||
},
|
||||
{
|
||||
"id": "19c9c18ad4bf22e1",
|
||||
"date": "Date: Thu, 26 Feb 2026 22:36:27 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Hungry Jacks",
|
||||
"len": 37596
|
||||
},
|
||||
{
|
||||
"id": "19c8ef02c0a0f590",
|
||||
"date": "Date: Tue, 24 Feb 2026 09:17:09 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Schnitz",
|
||||
"len": 38107
|
||||
},
|
||||
{
|
||||
"id": "19c6e7cb5565afd5",
|
||||
"date": "Date: Wed, 18 Feb 2026 02:03:12 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37418
|
||||
},
|
||||
{
|
||||
"id": "19c4c4b1a0efa1e0",
|
||||
"date": "Date: Wed, 11 Feb 2026 10:41:56 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from lahori fish n tikka",
|
||||
"len": 37166
|
||||
},
|
||||
{
|
||||
"id": "19c4013c5a7fac93",
|
||||
"date": "Date: Mon, 09 Feb 2026 01:46:02 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Subway",
|
||||
"len": 39212
|
||||
},
|
||||
{
|
||||
"id": "19c30b95bf183a2b",
|
||||
"date": "Date: Fri, 06 Feb 2026 02:12:59 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
|
||||
"len": 37776
|
||||
},
|
||||
{
|
||||
"id": "19c30b773199c2fb",
|
||||
"date": "Date: Fri, 06 Feb 2026 02:10:54 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
|
||||
"len": 37309
|
||||
},
|
||||
{
|
||||
"id": "19c27f770fae04f2",
|
||||
"date": "Date: Wed, 04 Feb 2026 09:24:13 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37440
|
||||
},
|
||||
{
|
||||
"id": "19c21316b26e066f",
|
||||
"date": "Date: Tue, 03 Feb 2026 01:50:11 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Punjabi bai sweets & Indian\r\n cuisine",
|
||||
"len": 39289
|
||||
},
|
||||
{
|
||||
"id": "19bf2e0fd25facda",
|
||||
"date": "Date: Sun, 25 Jan 2026 01:59:48 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 38432
|
||||
},
|
||||
{
|
||||
"id": "19bb18096ebd9bf5",
|
||||
"date": "Date: Mon, 12 Jan 2026 09:19:12 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||
"len": 42464
|
||||
},
|
||||
{
|
||||
"id": "19bac527ef5fb1d4",
|
||||
"date": "Date: Sun, 11 Jan 2026 09:10:45 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37833
|
||||
},
|
||||
{
|
||||
"id": "19baad6dd7f80a9f",
|
||||
"date": "Date: Sun, 11 Jan 2026 02:16:04 +0000 (UTC)",
|
||||
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||
"len": 37334
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,205 +0,0 @@
|
||||
[
|
||||
{
|
||||
"i": 0,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkeckxXAAAA",
|
||||
"date": "2026-07-22T03:41:19Z",
|
||||
"subject": "Your Wednesday afternoon order with Uber Eats",
|
||||
"len": 57778
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TzAAAA",
|
||||
"date": "2026-07-21T04:25:09Z",
|
||||
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||
"len": 61532
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TsAAAA",
|
||||
"date": "2026-07-20T23:17:05Z",
|
||||
"subject": "Your Tuesday morning order with Uber Eats",
|
||||
"len": 59110
|
||||
},
|
||||
{
|
||||
"i": 3,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkdK0voAAAA",
|
||||
"date": "2026-07-20T04:23:40Z",
|
||||
"subject": "Your Monday afternoon order with Uber Eats",
|
||||
"len": 66211
|
||||
},
|
||||
{
|
||||
"i": 4,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aigAAAA",
|
||||
"date": "2026-07-07T15:31:27Z",
|
||||
"subject": "[Family] Your Tuesday evening order with Uber Eats",
|
||||
"len": 80279
|
||||
},
|
||||
{
|
||||
"i": 5,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aicAAAA",
|
||||
"date": "2026-07-07T08:58:43Z",
|
||||
"subject": "Your Tuesday evening order with Uber Eats",
|
||||
"len": 73592
|
||||
},
|
||||
{
|
||||
"i": 6,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aibAAAA",
|
||||
"date": "2026-07-07T08:50:06Z",
|
||||
"subject": "[Family] Your Tuesday afternoon order with Uber Eats",
|
||||
"len": 78782
|
||||
},
|
||||
{
|
||||
"i": 7,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aiaAAAA",
|
||||
"date": "2026-07-07T08:46:17Z",
|
||||
"subject": "Your Tuesday evening order with Uber Eats",
|
||||
"len": 65886
|
||||
},
|
||||
{
|
||||
"i": 8,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah6AAAA",
|
||||
"date": "2026-07-05T15:51:11Z",
|
||||
"subject": "[Family] Your Sunday evening order with Uber Eats",
|
||||
"len": 82383
|
||||
},
|
||||
{
|
||||
"i": 9,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah5AAAA",
|
||||
"date": "2026-07-05T15:33:01Z",
|
||||
"subject": "[Family] Your Sunday evening order with Uber Eats",
|
||||
"len": 88958
|
||||
},
|
||||
{
|
||||
"i": 10,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahTAAAA",
|
||||
"date": "2026-07-03T10:04:15Z",
|
||||
"subject": "Your Friday evening order with Uber Eats",
|
||||
"len": 61485
|
||||
},
|
||||
{
|
||||
"i": 11,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahFAAAA",
|
||||
"date": "2026-07-02T22:57:18Z",
|
||||
"subject": "Your Friday morning order with Uber Eats",
|
||||
"len": 60751
|
||||
},
|
||||
{
|
||||
"i": 12,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkOIIcBAAAA",
|
||||
"date": "2026-06-27T04:21:08Z",
|
||||
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||
"len": 60720
|
||||
},
|
||||
{
|
||||
"i": 13,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVTAAAA",
|
||||
"date": "2026-06-22T10:19:49Z",
|
||||
"subject": "Your Monday evening order with Uber Eats",
|
||||
"len": 58303
|
||||
},
|
||||
{
|
||||
"i": 14,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zUwAAAA",
|
||||
"date": "2026-06-20T12:28:26Z",
|
||||
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||
"len": 60856
|
||||
},
|
||||
{
|
||||
"i": 15,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zURAAAA",
|
||||
"date": "2026-06-18T18:43:06Z",
|
||||
"subject": "Your Thursday evening order with Uber Eats",
|
||||
"len": 57881
|
||||
},
|
||||
{
|
||||
"i": 16,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkGJuBoAAAA",
|
||||
"date": "2026-06-15T19:32:17Z",
|
||||
"subject": "Your Monday evening order with Uber Eats",
|
||||
"len": 60838
|
||||
},
|
||||
{
|
||||
"i": 17,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj9_fg8AAAA",
|
||||
"date": "2026-06-02T11:27:03Z",
|
||||
"subject": "Your Tuesday evening order with Uber Eats",
|
||||
"len": 55147
|
||||
},
|
||||
{
|
||||
"i": 18,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj7_L67AAAA",
|
||||
"date": "2026-06-02T03:11:33Z",
|
||||
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||
"len": 60716
|
||||
},
|
||||
{
|
||||
"i": 19,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj66TeuAAAA",
|
||||
"date": "2026-05-29T02:47:06Z",
|
||||
"subject": "Your Friday afternoon order with Uber Eats",
|
||||
"len": 55127
|
||||
},
|
||||
{
|
||||
"i": 20,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj19QCWNwAAAA==",
|
||||
"date": "2026-05-23T03:00:14Z",
|
||||
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||
"len": 65562
|
||||
},
|
||||
{
|
||||
"i": 21,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjzoSJtAAAA",
|
||||
"date": "2026-05-18T10:50:25Z",
|
||||
"subject": "Your Monday evening order with Uber Eats",
|
||||
"len": 68144
|
||||
},
|
||||
{
|
||||
"i": 22,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjy8e6sAAAA",
|
||||
"date": "2026-05-17T03:13:05Z",
|
||||
"subject": "Your Sunday afternoon order with Uber Eats",
|
||||
"len": 62439
|
||||
},
|
||||
{
|
||||
"i": 23,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZyAAAAA",
|
||||
"date": "2026-05-15T09:45:15Z",
|
||||
"subject": "Your Friday evening order with Uber Eats",
|
||||
"len": 55113
|
||||
},
|
||||
{
|
||||
"i": 24,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxwAAAA",
|
||||
"date": "2026-05-14T15:02:24Z",
|
||||
"subject": "Your Thursday afternoon order with Uber Eats",
|
||||
"len": 58231
|
||||
},
|
||||
{
|
||||
"i": 25,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxtAAAA",
|
||||
"date": "2026-05-14T11:24:43Z",
|
||||
"subject": "Your Thursday evening order with Uber Eats",
|
||||
"len": 60745
|
||||
},
|
||||
{
|
||||
"i": 26,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eF4AAAA",
|
||||
"date": "2026-04-08T05:54:03Z",
|
||||
"subject": "Your Tuesday evening order with Uber Eats",
|
||||
"len": 68338
|
||||
},
|
||||
{
|
||||
"i": 27,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFyAAAA",
|
||||
"date": "2026-04-07T22:40:52Z",
|
||||
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||
"len": 52600
|
||||
},
|
||||
{
|
||||
"i": 28,
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFcAAAA",
|
||||
"date": "2026-04-06T05:23:40Z",
|
||||
"subject": "Your Sunday evening order with Uber Eats",
|
||||
"len": 59440
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"ut-00": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVAAAAA",
|
||||
"subject": "Your Sunday evening trip with Uber",
|
||||
"receivedAt": "2026-06-21T10:11:16Z",
|
||||
"sender": "noreply@uber.com"
|
||||
},
|
||||
"ut-01": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU-AAAA",
|
||||
"subject": "Your Sunday morning trip with Uber",
|
||||
"receivedAt": "2026-06-21T09:02:09Z",
|
||||
"sender": "noreply@uber.com"
|
||||
},
|
||||
"ut-summary": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU3AAAA",
|
||||
"subject": "Your Sunday morning trip with Uber",
|
||||
"receivedAt": "2026-06-20T22:27:54Z",
|
||||
"sender": "noreply@uber.com"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,531 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
import {
|
||||
comparisonBasis,
|
||||
baselineMonths,
|
||||
baselineAverage,
|
||||
spendThrough,
|
||||
prevMonth,
|
||||
} from "../../lib/partial-month";
|
||||
|
||||
/**
|
||||
* The three Priority 0 behaviours that shipped without a test (#155).
|
||||
*
|
||||
* `docs/ui-information-architecture-review.md` claimed Priority 0 was guarded;
|
||||
* `analytics-sql.test.ts` only ever covered the three SQL *fragments*
|
||||
* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3
|
||||
* and 6 of that list had nothing:
|
||||
*
|
||||
* 2. the spend-pace series must use the same spend semantics as the headline
|
||||
* printed directly above it;
|
||||
* 3. fees and interest must be bounded by an explicit, reported period;
|
||||
* 6. a month in progress must never be compared against complete ones.
|
||||
*
|
||||
* All three fail *silently* when they regress — no error, no empty screen, just
|
||||
* a number that quietly disagrees with the one beside it. That is precisely the
|
||||
* class the review flagged and the reason these are worth the fixture cost.
|
||||
*
|
||||
* The clock is frozen for every test in this file. Two of the three behaviours
|
||||
* are defined relative to "now" (which month is in progress, how far back the
|
||||
* default fees window reaches), so against the real clock the fixtures would
|
||||
* drift out of the routes' windows and the assertions would go vacuous — which
|
||||
* is the failure mode this file exists to prevent, one level up. Only `Date` is
|
||||
* faked: `pg` uses real timers for its connection handling.
|
||||
*/
|
||||
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
/** Frozen wall clock: the 15th, so the current month is genuinely half-done. */
|
||||
const NOW = new Date(2026, 7, 15, 10, 0, 0); // 2026-08-15, local time
|
||||
const CURRENT_MONTH = "2026-08";
|
||||
const ELAPSED = 15;
|
||||
|
||||
let monthlyGET: (req: NextRequest) => Promise<Response>;
|
||||
let dailyGET: (req: NextRequest) => Promise<Response>;
|
||||
let feesGET: (req: NextRequest) => Promise<Response>;
|
||||
let ownerId: number;
|
||||
|
||||
// One cast at the boundary: these are the two properties the analytics routes
|
||||
// read (`url` for the query string, `headers` for the auth header, which is
|
||||
// mocked out anyway), not a whole NextRequest.
|
||||
const req = (path: string) =>
|
||||
({ url: `http://localhost${path}`, headers: { get: () => null } }) as unknown as NextRequest;
|
||||
|
||||
const json = async (res: Response) => (await res.json()) as Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
|
||||
}));
|
||||
({ GET: monthlyGET } = await import("../../app/api/analytics/monthly/route"));
|
||||
({ GET: dailyGET } = await import("../../app/api/analytics/daily/route"));
|
||||
({ GET: feesGET } = await import("../../app/api/analytics/fees/route"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
|
||||
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
|
||||
ownerId = a.rows[0].id;
|
||||
otherId = b.rows[0].id;
|
||||
});
|
||||
|
||||
let otherId: number;
|
||||
|
||||
/** A manual transaction owned by `owner`. Returns its id. */
|
||||
async function txn(over: {
|
||||
date: string;
|
||||
amount: number;
|
||||
type?: string;
|
||||
category?: string;
|
||||
owner?: number;
|
||||
description?: string;
|
||||
interest_amount?: number;
|
||||
reconciled_with_id?: number;
|
||||
}): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(owner_id, statement_id, transaction_date, description, amount,
|
||||
transaction_type, category, interest_amount, reconciled_with_id, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, 0) RETURNING id`,
|
||||
[
|
||||
over.owner ?? ownerId,
|
||||
over.date,
|
||||
over.description ?? `fixture ${over.date} ${over.amount}`,
|
||||
over.amount,
|
||||
over.type ?? "debit",
|
||||
over.category ?? "groceries",
|
||||
over.interest_amount ?? null,
|
||||
over.reconciled_with_id ?? null,
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
async function split(txnId: number, participantId: number, pct: number) {
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[txnId, participantId, pct]
|
||||
);
|
||||
}
|
||||
|
||||
async function tag(txnId: number, name: string) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`,
|
||||
[name]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2)`,
|
||||
[txnId, t.rows[0].id]
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 2 — the pace chart and the headline must agree
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pace chart used to sum gross `amount_aud ?? amount` over
|
||||
* `transaction_type = 'debit'` in the browser. Measured on live data it ended
|
||||
* July at $4,747.31 against a headline of $3,597.10 — a 32% overstatement of
|
||||
* the number printed directly above it, with both labelled "spend".
|
||||
*
|
||||
* `/api/analytics/daily` fixed that by building the series from the same
|
||||
* fragments as `/monthly`. Nothing has been asserting it since, and the two
|
||||
* routes are separate files: the drift they were merged to prevent can
|
||||
* reappear the moment one is edited alone.
|
||||
*
|
||||
* Every fixture below is a shape where the naive gross-debit sum and the real
|
||||
* spend definition disagree. The last test proves that — without it, parity
|
||||
* could hold simply because the fixtures were too plain to discriminate, and
|
||||
* the test would pass against a route that had regressed.
|
||||
*/
|
||||
describe("Priority 0 item 2 — spend pace matches the headline", () => {
|
||||
/** Everything the two routes are supposed to treat identically. */
|
||||
async function seedDivergentMonth(month: string) {
|
||||
const d = (day: number) => `${month}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
// Plain debit — both definitions agree on this one.
|
||||
await txn({ date: d(2), amount: 100, category: "groceries" });
|
||||
|
||||
// Half mine. Gross says 200; my share is 100.
|
||||
const shared = await txn({ date: d(3), amount: 200, category: "groceries" });
|
||||
await split(shared, ownerId, 50);
|
||||
await split(shared, otherId, 50);
|
||||
|
||||
// A refund. Ignored entirely by a debit-only sum; must cancel spend here.
|
||||
await txn({ date: d(5), amount: 40, type: "refund", category: "groceries" });
|
||||
|
||||
// Fee and interest rows: spend, but not type 'debit'.
|
||||
await txn({ date: d(6), amount: 15, type: "fee", category: "fees" });
|
||||
await txn({ date: d(7), amount: 25, type: "interest", category: "fees" });
|
||||
|
||||
// An itemised loan repayment: type 'payment', so a debit-only sum skips it,
|
||||
// but its interest portion is real spend and its principal is not.
|
||||
await txn({
|
||||
date: d(8),
|
||||
amount: 1200,
|
||||
type: "payment",
|
||||
category: "loan",
|
||||
interest_amount: 300,
|
||||
});
|
||||
|
||||
// The manual row a statement line superseded — double count if counted.
|
||||
const survivor = await txn({ date: d(9), amount: 60, category: "dining" });
|
||||
await txn({ date: d(9), amount: 60, category: "dining", reconciled_with_id: survivor });
|
||||
|
||||
// Categories that are not spend at all.
|
||||
await txn({ date: d(10), amount: 5000, category: "transfers" });
|
||||
await txn({ date: d(11), amount: 2000, category: "investment" });
|
||||
await txn({ date: d(12), amount: 3000, type: "credit", category: "income" });
|
||||
|
||||
// A `family`-tagged row: excluded from budget spend by EXCLUDE_NON_BUDGET_TAGS.
|
||||
const family = await txn({ date: d(13), amount: 500, category: "groceries" });
|
||||
await tag(family, "family");
|
||||
|
||||
// Someone else's row I hold a split on — mine only to the extent of the split.
|
||||
const theirs = await txn({ date: d(14), amount: 80, category: "dining", owner: otherId });
|
||||
await split(theirs, ownerId, 25);
|
||||
await split(theirs, otherId, 75);
|
||||
}
|
||||
|
||||
/** Sum the sparse daily map for one month. */
|
||||
const sumDaily = (daily: Record<string, Record<number, number>>, month: string) =>
|
||||
Object.values(daily[month] ?? {}).reduce((s, v) => s + v, 0);
|
||||
|
||||
it("the daily series sums to the monthly headline, to the cent", async () => {
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
|
||||
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
|
||||
const pace = sumDaily(daily.daily as Record<string, Record<number, number>>, "2026-06");
|
||||
|
||||
// Cents, not exact float equality: the two routes group at different grains
|
||||
// (month+category vs month+day+category) and both carry 4dp, so they can
|
||||
// differ below a cent by construction. The displayed grain is what has to
|
||||
// agree — that IS the bug, the chart ending the month a few cents off the
|
||||
// headline it sits under.
|
||||
expect(Math.round(pace * 100) / 100).toBe(headline);
|
||||
});
|
||||
|
||||
it("agrees per category, not just in total", async () => {
|
||||
// A total can match while two categories are wrong in opposite directions,
|
||||
// and the category drill-down is a real surface on this page.
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
|
||||
const byCategory = (daily.byCategory as Record<string, Record<string, Record<number, number>>>)["2026-06"];
|
||||
const rows = monthly.rows as { category: string; spent: Record<string, number> }[];
|
||||
|
||||
expect(Object.keys(byCategory).sort()).toEqual(
|
||||
rows.filter((r) => r.spent["2026-06"] !== undefined).map((r) => r.category).sort()
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
const expected = r.spent["2026-06"];
|
||||
if (expected === undefined) continue;
|
||||
const got = Object.values(byCategory[r.category] ?? {}).reduce((s, v) => s + v, 0);
|
||||
expect([r.category, Math.round(got * 100) / 100]).toEqual([
|
||||
r.category,
|
||||
Math.round(expected * 100) / 100,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds across every month in the window, not only the seeded one", async () => {
|
||||
await seedDivergentMonth("2026-05");
|
||||
await seedDivergentMonth("2026-06");
|
||||
await seedDivergentMonth("2026-07");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
const totals = monthly.totals as Record<string, { spent: number }>;
|
||||
const dailyMap = daily.daily as Record<string, Record<number, number>>;
|
||||
|
||||
for (const m of monthly.months as string[]) {
|
||||
expect([m, Math.round(sumDaily(dailyMap, m) * 100) / 100]).toEqual([m, totals[m].spent]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the fixtures actually discriminate — a gross debit sum does NOT agree", async () => {
|
||||
// The placebo. Parity between two identical-by-construction queries is easy
|
||||
// to assert and worthless if no fixture can tell the definitions apart.
|
||||
// This pins the gap the old client-side series had.
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
|
||||
|
||||
const naive = await pool.query<{ total: string }>(
|
||||
`SELECT COALESCE(SUM(COALESCE(amount_aud, amount)), 0)::numeric(14,2) AS total
|
||||
FROM transactions
|
||||
WHERE transaction_type = 'debit'
|
||||
AND transaction_date >= '2026-06-01' AND transaction_date < '2026-07-01'`
|
||||
);
|
||||
|
||||
expect(Number(naive.rows[0].total)).toBeGreaterThan(headline);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 3 — fees and interest are bounded by an explicit, reported period
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/api/analytics/fees` used to aggregate every statement ever imported with no
|
||||
* date filter, and the UI printed the result with no period label — so a
|
||||
* lifetime-to-date total read as a current-period one and grew forever. It was
|
||||
* overstating the last 12 months by roughly $2,700 of fees.
|
||||
*
|
||||
* The regression is silent in the worst way: the number is always plausible and
|
||||
* always too big, and it gets worse the longer the app is used.
|
||||
*/
|
||||
describe("Priority 0 item 3 — fees and interest carry a date range", () => {
|
||||
/** A statement dated by the period it covers, carrying fees and interest. */
|
||||
async function statement(billingEnd: string, fees: number, interest: number, bank = "Fixture Bank") {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO statements
|
||||
(filename, bank_name, account_number, billing_end_date, currency,
|
||||
fees_charged, interest_charged, owner_id)
|
||||
VALUES ($1, $2, '0000', $3, 'AUD', $4, $5, $6) RETURNING id`,
|
||||
[`fees-${billingEnd}-${bank}.pdf`, bank, billingEnd, fees, interest, ownerId]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Inside the default 12-month window (opens 2025-09-01 from a 2026-08-15 now).
|
||||
await statement("2026-07-31", 10, 5);
|
||||
await statement("2025-10-31", 20, 7);
|
||||
// Outside it — the tail that used to be silently included.
|
||||
await statement("2024-06-30", 1000, 500);
|
||||
await statement("2019-01-31", 2500, 900);
|
||||
});
|
||||
|
||||
it("defaults to the last 12 months and says so", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees")));
|
||||
const period = body.period as { months: number; from: string; to: string; all_time: boolean };
|
||||
|
||||
expect(period.months).toBe(12);
|
||||
expect(period.all_time).toBe(false);
|
||||
// The window opens on the 1st of the month 11 months back, and toDateStr()
|
||||
// must not shift it a day earlier — `toISOString()` on a local-midnight
|
||||
// Date does exactly that in any timezone east of Greenwich, which is how
|
||||
// every analytics window used to be a day early.
|
||||
expect(period.from).toBe("2025-09-01");
|
||||
expect(period.to).toBe("2026-09-01");
|
||||
|
||||
expect(body.total_fees).toBe(30);
|
||||
expect(body.total_interest).toBe(12);
|
||||
});
|
||||
|
||||
it("excludes the older tail — the $2,700-of-fees overstatement", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees")));
|
||||
// 3530 of fees exist; only 30 are in the last 12 months.
|
||||
expect(body.total_fees).toBe(30);
|
||||
expect(body.total_fees).not.toBe(3530);
|
||||
});
|
||||
|
||||
it("months=0 asks for all time deliberately, and is labelled as such", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=0")));
|
||||
const period = body.period as { months: number; from: null; to: null; all_time: boolean };
|
||||
|
||||
expect(period.all_time).toBe(true);
|
||||
// A null range is the honest answer for an unbounded total; a from/to here
|
||||
// would be a period claim the number does not actually make.
|
||||
expect(period.from).toBeNull();
|
||||
expect(period.to).toBeNull();
|
||||
|
||||
expect(body.total_fees).toBe(3530);
|
||||
expect(body.total_interest).toBe(1412);
|
||||
});
|
||||
|
||||
it("honours a narrower explicit window", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=3")));
|
||||
const period = body.period as { months: number; from: string };
|
||||
expect(period.months).toBe(3);
|
||||
expect(period.from).toBe("2026-06-01");
|
||||
// Only the 2026-07-31 statement falls inside three months.
|
||||
expect(body.total_fees).toBe(10);
|
||||
expect(body.total_interest).toBe(5);
|
||||
});
|
||||
|
||||
it("bounds the transaction-level fee lines by the same window", async () => {
|
||||
// Two halves of the same answer, and only the statement half was ever
|
||||
// date-filtered in the original. A bounded total beside an unbounded list
|
||||
// is the same defect wearing the fix.
|
||||
await txn({ date: "2026-07-10", amount: 12, type: "fee", category: "fees" });
|
||||
await txn({ date: "2020-02-10", amount: 99, type: "fee", category: "fees" });
|
||||
|
||||
const bounded = await json(await feesGET(req("/api/analytics/fees")));
|
||||
const all = await json(await feesGET(req("/api/analytics/fees?months=0")));
|
||||
|
||||
expect((bounded.transactions as unknown[]).length).toBe(1);
|
||||
expect((all.transactions as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps a nonsense months parameter instead of unbounding the total", async () => {
|
||||
// A NaN reaching `months` would make `new Date(y, NaN, 1)` an Invalid Date
|
||||
// and the window meaningless. Falling back to 12 keeps the default claim.
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=banana")));
|
||||
expect((body.period as { months: number }).months).toBe(12);
|
||||
expect(body.total_fees).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 6 — a month in progress is never compared against complete ones
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Two rules, both silent when broken (see src/lib/partial-month.ts):
|
||||
*
|
||||
* 1. the in-progress month never enters a baseline;
|
||||
* 2. when it is the month selected, both sides of every comparison are
|
||||
* truncated to the same day.
|
||||
*
|
||||
* These are driven off real `/api/analytics/daily` output rather than a
|
||||
* hand-built map, because rule 2 depends on that route's day-of-month
|
||||
* granularity — a route returning month totals only would make the rule
|
||||
* unimplementable while every pure-function test still passed.
|
||||
*/
|
||||
describe("Priority 0 item 6 — partial-month comparisons", () => {
|
||||
/** `perDay` dollars on each of days 1..n of `month`. */
|
||||
async function evenMonth(month: string, perDay: number, days: number) {
|
||||
for (let d = 1; d <= days; d++) {
|
||||
await txn({ date: `${month}-${String(d).padStart(2, "0")}`, amount: perDay, category: "groceries" });
|
||||
}
|
||||
}
|
||||
|
||||
async function dailyMap() {
|
||||
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
|
||||
return body.daily as Record<string, Record<number, number>>;
|
||||
}
|
||||
|
||||
it("recognises the in-progress month and the day to compare through", () => {
|
||||
expect(comparisonBasis(CURRENT_MONTH, NOW)).toEqual({ isPartial: true, compareDay: ELAPSED });
|
||||
// A complete month compares through its last day, so the same code path
|
||||
// serves both cases and `spendThrough` becomes a no-op.
|
||||
expect(comparisonBasis("2026-06", NOW)).toEqual({ isPartial: false, compareDay: 30 });
|
||||
expect(comparisonBasis("2026-02", NOW)).toEqual({ isPartial: false, compareDay: 28 });
|
||||
});
|
||||
|
||||
it("keeps the in-progress month out of the baseline", () => {
|
||||
const totals: Record<string, { spent: number }> = {
|
||||
"2026-05": { spent: 100 },
|
||||
"2026-06": { spent: 100 },
|
||||
"2026-07": { spent: 100 },
|
||||
[CURRENT_MONTH]: { spent: 40 }, // half a month of spend
|
||||
};
|
||||
const months = ["2026-05", "2026-06", "2026-07", CURRENT_MONTH];
|
||||
|
||||
// Baseline for a complete month: every other complete month with data.
|
||||
expect(baselineMonths(months, "2026-07", totals, NOW)).toEqual(["2026-05", "2026-06"]);
|
||||
// Baseline for the month in progress: itself excluded twice over.
|
||||
expect(baselineMonths(months, CURRENT_MONTH, totals, NOW)).toEqual([
|
||||
"2026-05",
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
]);
|
||||
// The partial month is never in it. Averaging its 40 in would drag a
|
||||
// baseline of 100 down to 85 and make an ordinary month look extravagant.
|
||||
expect(baselineMonths(months, "2026-07", totals, NOW)).not.toContain(CURRENT_MONTH);
|
||||
});
|
||||
|
||||
it("drops a month with no data rather than averaging a zero into the baseline", () => {
|
||||
const totals: Record<string, { spent: number }> = { "2026-05": { spent: 100 }, "2026-06": { spent: 0 } };
|
||||
expect(baselineMonths(["2026-05", "2026-06"], "2026-07", totals, NOW)).toEqual(["2026-05"]);
|
||||
});
|
||||
|
||||
it("compares the month in progress through the same day of each prior month", async () => {
|
||||
// $10/day everywhere. Prior months ran the full month; the current one has
|
||||
// only reached the 15th, so it holds $150 against their $300-odd.
|
||||
await evenMonth("2026-06", 10, 30);
|
||||
await evenMonth("2026-07", 10, 31);
|
||||
await evenMonth(CURRENT_MONTH, 10, ELAPSED);
|
||||
|
||||
const daily = await dailyMap();
|
||||
const totals: Record<string, { spent: number }> = {
|
||||
"2026-06": { spent: 300 },
|
||||
"2026-07": { spent: 310 },
|
||||
[CURRENT_MONTH]: { spent: 150 },
|
||||
};
|
||||
const months = ["2026-06", "2026-07", CURRENT_MONTH];
|
||||
const { isPartial, compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
|
||||
const baseline = baselineMonths(months, CURRENT_MONTH, totals, NOW);
|
||||
|
||||
const mine = spendThrough(daily[CURRENT_MONTH], compareDay);
|
||||
const avg = baselineAverage(baseline, { isPartial, compareDay, daily, totals });
|
||||
|
||||
// Truncated on both sides, this month is exactly typical.
|
||||
expect(mine).toBe(150);
|
||||
expect(avg).toBe(150);
|
||||
expect(Math.round(((mine - avg) / avg) * 100)).toBe(0);
|
||||
|
||||
// Untruncated — the bug — it reads as 51% below average on identical
|
||||
// spending, purely because half the month has not happened yet.
|
||||
const naiveAvg = baseline.reduce((s, m) => s + totals[m].spent, 0) / baseline.length;
|
||||
expect(Math.round(((mine - naiveAvg) / naiveAvg) * 100)).toBe(-51);
|
||||
});
|
||||
|
||||
it("leaves a complete month compared on full totals", async () => {
|
||||
await evenMonth("2026-06", 10, 30);
|
||||
await evenMonth("2026-07", 20, 31);
|
||||
|
||||
const daily = await dailyMap();
|
||||
const totals: Record<string, { spent: number }> = { "2026-06": { spent: 300 }, "2026-07": { spent: 620 } };
|
||||
const months = ["2026-06", "2026-07"];
|
||||
const { isPartial, compareDay } = comparisonBasis("2026-07", NOW);
|
||||
const baseline = baselineMonths(months, "2026-07", totals, NOW);
|
||||
|
||||
expect(isPartial).toBe(false);
|
||||
// Truncation must not quietly apply to a finished month: June's full 30
|
||||
// days are the right comparison for July's full 31.
|
||||
expect(baselineAverage(baseline, { isPartial, compareDay, daily, totals })).toBe(300);
|
||||
});
|
||||
|
||||
it("truncates the top-movers comparison on both sides", async () => {
|
||||
// The movers row asks "biggest category moves vs last month". With one side
|
||||
// full and the other half-done, every category reads as a large decrease.
|
||||
await evenMonth("2026-07", 10, 31); // $310 in July
|
||||
await evenMonth(CURRENT_MONTH, 10, ELAPSED); // $150 so far in August
|
||||
|
||||
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
|
||||
const byCategory = body.byCategory as Record<string, Record<string, Record<number, number>>>;
|
||||
const { compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
|
||||
const pm = prevMonth(CURRENT_MONTH);
|
||||
expect(pm).toBe("2026-07");
|
||||
|
||||
const now = spendThrough(byCategory[CURRENT_MONTH]?.groceries, compareDay);
|
||||
const before = spendThrough(byCategory[pm]?.groceries, compareDay);
|
||||
expect(now - before).toBe(0);
|
||||
|
||||
// Against July's full month it would report a $160 fall that never happened.
|
||||
const fullBefore = Object.values(byCategory[pm].groceries).reduce((s, v) => s + v, 0);
|
||||
expect(now - fullBefore).toBe(-160);
|
||||
});
|
||||
|
||||
it("crosses a year boundary correctly", () => {
|
||||
expect(prevMonth("2026-01")).toBe("2025-12");
|
||||
expect(comparisonBasis("2025-12", NOW)).toEqual({ isPartial: false, compareDay: 31 });
|
||||
});
|
||||
|
||||
it("handles February in a leap year", () => {
|
||||
// 2028 is a leap year; a hardcoded 28 would silently drop the 29th.
|
||||
expect(comparisonBasis("2028-02", new Date(2028, 5, 1)).compareDay).toBe(29);
|
||||
});
|
||||
});
|
||||
@@ -1,416 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import {
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
NATIVE_CURRENCY,
|
||||
AMOUNT_UNCONVERTED,
|
||||
INVESTMENT_SIGNED,
|
||||
MY_SPEND_SCOPE,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
myShare,
|
||||
mySplitOf,
|
||||
} from "../../lib/analytics-sql";
|
||||
import { MY_SHARE_PCT } from "../../lib/queries";
|
||||
|
||||
/**
|
||||
* These fragments are the ones that drifted.
|
||||
*
|
||||
* The reconciled-row exclusion lived only in `queries.ts` for months while every
|
||||
* analytics route counted the superseded manual rows as spend — 48 rows, $4,474
|
||||
* of double count, invisible because the transaction list (which did exclude
|
||||
* them) looked right. The currency expression has the same shape of risk: it is
|
||||
* read by two call sites with two different denomination conventions.
|
||||
*
|
||||
* Assertions are per-row against known fixtures rather than against aggregate
|
||||
* totals, so a change in unrelated data cannot mask a regression.
|
||||
*/
|
||||
|
||||
async function scratchTxn(cols: string, vals: string, params: unknown[] = []) {
|
||||
const row = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (${cols}) VALUES (${vals}) RETURNING id`,
|
||||
params
|
||||
);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** Does this row survive the predicate? */
|
||||
async function passes(predicate: string, id: number): Promise<boolean> {
|
||||
const rows = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1 AND (${predicate})`,
|
||||
[id]
|
||||
);
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
describe("EXCLUDE_RECONCILED_SOURCE", () => {
|
||||
it("drops a manual row that a statement line has superseded", async () => {
|
||||
const survivor = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Analytics fixture — survivor', 10.00, 'debit'"
|
||||
);
|
||||
const superseded = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, reconciled_with_id",
|
||||
"'2026-03-01','Analytics fixture — superseded', 10.00, 'debit', $1",
|
||||
[survivor]
|
||||
);
|
||||
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, superseded)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an ordinary manual row that was never reconciled", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Analytics fixture — unreconciled', 10.00, 'debit'"
|
||||
);
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a credits order row — nothing ever sets reconciled_with_id on one", async () => {
|
||||
// The order slice records the card match in expense_metadata, not on the
|
||||
// transaction, and needsCardMatch() holds these out of the reconcile queue.
|
||||
// If that ever changes, this exclusion would start eating real spend.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, payment_method",
|
||||
"'2026-03-01','Order - Analytics fixture', 25.00, 'debit', 'credits'"
|
||||
);
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NATIVE_CURRENCY", () => {
|
||||
async function currencyOf(id: number) {
|
||||
const row = await queryRow<{ ccy: string; unconverted: boolean }>(
|
||||
`SELECT ${NATIVE_CURRENCY} AS ccy, ${AMOUNT_UNCONVERTED} AS unconverted
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return row!;
|
||||
}
|
||||
|
||||
it("reads a statement-less order row's own currency, not 'AUD'", async () => {
|
||||
// The bug this guards: sourcing currency from s.currency alone labelled
|
||||
// every foreign order row AUD, because an order has no statement.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, payment_method, foreign_currency_amount, foreign_currency_code",
|
||||
"'2026-03-01','Order - Foreign fixture', 3500.00, 'debit', 'credits', 3500.00, 'LKR'"
|
||||
);
|
||||
const { ccy, unconverted } = await currencyOf(id);
|
||||
expect(ccy).toBe("LKR");
|
||||
// No amount_aud: the ingest path refuses to assert an FX rate it lacks, so
|
||||
// this row's AUD value is genuinely unknown and must be reported as such.
|
||||
expect(unconverted).toBe(true);
|
||||
});
|
||||
|
||||
it("labels a statement-less wallet row AUD, because its amount already is", async () => {
|
||||
// Wallet rows store a provisional AUD figure in amount AND amount_aud, and
|
||||
// the native figure in foreign_currency_amount. Reading the foreign code
|
||||
// here printed "INR 9.42" for an INR 524 tap (txn 8162).
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, payment_method, source, source_ref, foreign_currency_amount, foreign_currency_code",
|
||||
"'2026-10-03','Wallet foreign fixture', 9.42, 9.42, 'debit', 'card', 'wallet', 'evt:fixture-native-ccy', 524.00, 'INR'"
|
||||
);
|
||||
const { ccy, unconverted } = await currencyOf(id);
|
||||
expect(ccy).toBe("AUD");
|
||||
expect(unconverted).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers the statement's currency over the foreign-charge record", async () => {
|
||||
// Opposite convention: on an AUD statement, `amount` is AUD and
|
||||
// foreign_currency_code merely notes what was originally charged. Reading
|
||||
// the foreign code here would mislabel an AUD row as USD.
|
||||
// owner_id is NOT NULL DEFAULT 1 with an FK to participants, so this insert
|
||||
// relied on a participant with id 1 already existing — true only when a
|
||||
// sibling test file had seeded one and left it behind. On a freshly reset
|
||||
// database it failed the FK. Own the fixture instead of inheriting it.
|
||||
const owner = await queryRow<{ id: number }>(
|
||||
`INSERT INTO participants (name) VALUES ('Analytics fixture — owner') RETURNING id`
|
||||
);
|
||||
const stmt = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
|
||||
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf', $1) RETURNING id`,
|
||||
[owner!.id]
|
||||
);
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
|
||||
"'2026-03-01','Overseas purchase fixture', 45.00, 45.00, 'debit', $1, 30.00, 'USD'",
|
||||
[stmt!.id]
|
||||
);
|
||||
const { ccy, unconverted } = await currencyOf(id);
|
||||
expect(ccy).toBe("AUD");
|
||||
expect(unconverted).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults a plain manual row to AUD", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Plain manual fixture', 12.00, 'debit'"
|
||||
);
|
||||
expect((await currencyOf(id)).ccy).toBe("AUD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("INVESTMENT_SIGNED", () => {
|
||||
/** The signed value the investments line would attribute to this row. */
|
||||
async function signedValue(id: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string }>(
|
||||
`SELECT (${INVESTMENT_SIGNED})::text AS v FROM transactions t WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return Number(row!.v);
|
||||
}
|
||||
|
||||
it("counts a contribution positive", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — deposit', 5000.00, 'debit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(5000);
|
||||
});
|
||||
|
||||
it("counts a withdrawal negative so it nets against contributions", async () => {
|
||||
// The $25,000 Raiz withdrawal that made March 2026 read as a $38,615.34
|
||||
// investing month when it was net -$11,384.66.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-19','Investment fixture — withdrawal', 25000.00, 'credit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(-25000);
|
||||
});
|
||||
|
||||
it("a deposit and an equal withdrawal net to zero", async () => {
|
||||
const inId = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — net in', 1000.00, 'debit', 'investment'"
|
||||
);
|
||||
const outId = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-02','Investment fixture — net out', 1000.00, 'credit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(inId) + await signedValue(outId)).toBe(0);
|
||||
});
|
||||
|
||||
it("prefers amount_aud over the native amount", async () => {
|
||||
// The IBKR rows are USD; the line is denominated in AUD.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, category",
|
||||
"'2026-07-25','Investment fixture — foreign', 10000.00, 14310.00, 'debit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(14310);
|
||||
});
|
||||
|
||||
it("treats a refund like a withdrawal", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — reversal', 1500.00, 'refund', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(-1500);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whose expense is it — the gate that decides, and the share that scales it.
|
||||
*
|
||||
* Both fragments changed together on 2026-08-15 and neither is safe alone.
|
||||
* `OWNER_SCOPE = $1` was the gate on every spend analytic, with `mySplitOf`
|
||||
* scaling within it, so a row someone else paid for contributed nothing to my
|
||||
* spend no matter what share I held — 167 rows / $3,210.91 of my own share
|
||||
* missing from Jan–Jul 2026, while the balance queries booked the debt
|
||||
* correctly. Widening the gate without also fixing `myShare` is worse than
|
||||
* leaving it: the old remainder fallback hands the viewer the PAYER's share on
|
||||
* a row with no split of theirs, which on an unsplit row is the full amount.
|
||||
*
|
||||
* So the discriminating cases here are all ones where I hold NO split row on
|
||||
* someone else's transaction. That is where old and new disagree, and it is the
|
||||
* only place the widened gate could do damage.
|
||||
*/
|
||||
describe("MY_SPEND_SCOPE / myShare", () => {
|
||||
let me = 0;
|
||||
let them = 0;
|
||||
let third = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mk = async (name: string) =>
|
||||
(await queryRow<{ id: number }>(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[name]
|
||||
))!.id;
|
||||
me = await mk("Share fixture — viewer");
|
||||
them = await mk("Share fixture — payer");
|
||||
third = await mk("Share fixture — third party");
|
||||
});
|
||||
|
||||
/** A transaction owned by `owner`, optionally split, optionally overridden. */
|
||||
async function fixture(
|
||||
label: string,
|
||||
owner: number,
|
||||
splits: [number, number][] = [],
|
||||
myShareOverride?: number
|
||||
) {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category, owner_id",
|
||||
`'2026-06-01', $1, 100.00, 'debit', 'groceries', $2`,
|
||||
[`Share fixture — ${label}`, owner]
|
||||
);
|
||||
for (const [pid, pct] of splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[id, pid, pct]
|
||||
);
|
||||
}
|
||||
if (myShareOverride !== undefined) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_overrides (transaction_id, my_share_percent) VALUES ($1, $2)`,
|
||||
[id, myShareOverride]
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const JOINS = `
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1`;
|
||||
|
||||
async function inScope(id: number, viewer: number): Promise<boolean> {
|
||||
const rows = await queryRaw(
|
||||
`SELECT t.id ${JOINS} WHERE t.id = $2 AND ${MY_SPEND_SCOPE()}`,
|
||||
[viewer, id]
|
||||
);
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
async function share(id: number, viewer: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string }>(
|
||||
`SELECT (${myShare()})::numeric AS v ${JOINS} WHERE t.id = $2`,
|
||||
[viewer, id]
|
||||
);
|
||||
return Number(row!.v);
|
||||
}
|
||||
|
||||
/** What this row contributes to the viewer's net spend. */
|
||||
async function spend(id: number, viewer: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string | null }>(
|
||||
`SELECT SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) AS v ${JOINS}
|
||||
WHERE t.id = $2 AND ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS}`,
|
||||
[viewer, id]
|
||||
);
|
||||
return Number(row?.v ?? 0);
|
||||
}
|
||||
|
||||
describe("the gate", () => {
|
||||
it("admits a row someone else paid for that I hold a split on", async () => {
|
||||
const id = await fixture("their shop, my half", them, [[me, 50], [them, 50]]);
|
||||
expect(await inScope(id, me)).toBe(true);
|
||||
});
|
||||
|
||||
it("still admits a row I paid for with no splits at all", async () => {
|
||||
const id = await fixture("mine alone", me);
|
||||
expect(await inScope(id, me)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a row someone else paid for that I have no split on", async () => {
|
||||
const id = await fixture("not mine", them, [[them, 60], [third, 40]]);
|
||||
expect(await inScope(id, me)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my share of someone else's row", () => {
|
||||
it("is my split percent", async () => {
|
||||
const id = await fixture("their shop, my quarter", them, [[me, 25], [them, 75]]);
|
||||
expect(await share(id, me)).toBe(25);
|
||||
});
|
||||
|
||||
it("is 0 with no split of mine — NOT the payer's remainder", async () => {
|
||||
// The load-bearing case. An unsplit row someone else paid for has no
|
||||
// `ts.share_percent`, so the old `100 - everyone else` fallback returned
|
||||
// 100 and would have moved their entire grocery bill onto my spend the
|
||||
// moment the gate widened.
|
||||
const id = await fixture("entirely theirs", them);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
expect(await spend(id, me)).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores my_share_percent, which can only mean the owner's share", async () => {
|
||||
// One unscoped column per transaction, writable by anyone who can see the
|
||||
// row. Reading it as the viewer's share on someone else's transaction
|
||||
// would be a guess.
|
||||
const id = await fixture("their row, their override", them, [], 30);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my share of my own row is unchanged", () => {
|
||||
it("prefers an explicit split row for me", async () => {
|
||||
const id = await fixture("mine, split explicitly", me, [[me, 35], [them, 65]]);
|
||||
expect(await share(id, me)).toBe(35);
|
||||
});
|
||||
|
||||
it("falls back to the my_share_percent override", async () => {
|
||||
const id = await fixture("mine, overridden", me, [], 80);
|
||||
expect(await share(id, me)).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to whatever is left after everyone else", async () => {
|
||||
const id = await fixture("mine, remainder", me, [[them, 40]]);
|
||||
expect(await share(id, me)).toBe(60);
|
||||
});
|
||||
|
||||
it("is 0 when a row I paid for is allocated entirely to someone else", async () => {
|
||||
const id = await fixture("mine, all theirs", me, [[them, 100]]);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the regression it was written for", () => {
|
||||
it("counts my half of a shop the other person paid for", async () => {
|
||||
const id = await fixture("the grocery shop", them, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("still counts my half of the same shop when I paid", async () => {
|
||||
const id = await fixture("the same shop, my card", me, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("counts my share whether or not the split is settled", async () => {
|
||||
// `myShare` must not filter on `settled` — half a shop was my expense
|
||||
// whether or not the other half was repaid. Same rule, payer-side.
|
||||
const id = await fixture("settled shop", them, [[me, 50], [them, 50]]);
|
||||
await queryRaw(`UPDATE transaction_splits SET settled = true WHERE transaction_id = $1`, [id]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("never counts one row for both people at full value", async () => {
|
||||
const id = await fixture("one shop, two people", them, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me) + await spend(id, them)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the transactions-list mirror agrees", () => {
|
||||
it("MY_SHARE_PCT matches myShare on every fixture shape", async () => {
|
||||
const shapes: [string, number, [number, number][], number | undefined][] = [
|
||||
["mirror A", them, [[me, 25], [them, 75]], undefined],
|
||||
["mirror B", them, [], undefined],
|
||||
["mirror C", them, [], 30],
|
||||
["mirror D", me, [[me, 35], [them, 65]], undefined],
|
||||
["mirror E", me, [], 80],
|
||||
["mirror F", me, [[them, 40]], undefined],
|
||||
["mirror G", me, [], undefined],
|
||||
];
|
||||
for (const [label, owner, splits, override] of shapes) {
|
||||
const id = await fixture(label, owner, splits, override);
|
||||
const mirrored = await queryRow<{ v: string }>(
|
||||
`SELECT (${MY_SHARE_PCT})::numeric AS v ${JOINS} WHERE t.id = $2`,
|
||||
[me, id]
|
||||
);
|
||||
expect([label, Number(mirrored!.v)]).toEqual([label, await share(id, me)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants, mockDbWithPool } from "./helpers";
|
||||
|
||||
/**
|
||||
* Upcoming annual charges from payments alone.
|
||||
*
|
||||
* The exclusions carry this file. Anything can be made to find a card annual
|
||||
* fee; the difficulty is not also reporting the monthly account-servicing fee,
|
||||
* the per-transfer Wise charge, and the restaurant visited twice a year apart.
|
||||
* Measured on the real ledger, a naive "two charges ~365 days apart" ranks a
|
||||
* bakery first with 202 such pairs.
|
||||
*/
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let getUpcomingAnnualCharges: typeof import("../../lib/annual-charges").getUpcomingAnnualCharges;
|
||||
|
||||
let acctSeq = 0;
|
||||
|
||||
/** A statement plus one transaction on it, for a named account. */
|
||||
async function charge(
|
||||
bank: string,
|
||||
acct: string,
|
||||
date: string,
|
||||
description: string,
|
||||
amount: number,
|
||||
type = "fee"
|
||||
) {
|
||||
const key = `${bank}:${acct}`;
|
||||
let stId = stmtCache.get(`${key}:${date}`);
|
||||
if (!stId) {
|
||||
const st = await pool.query(
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ($4, $1, $2, $3::date - 20, $3::date + 10, $5) RETURNING id`,
|
||||
[acct, bank, date, `s${acctSeq++}.pdf`, ownerId]
|
||||
);
|
||||
stId = st.rows[0].id as number;
|
||||
stmtCache.set(`${key}:${date}`, stId);
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[stId, date, description, amount, type, ownerId]
|
||||
);
|
||||
}
|
||||
let stmtCache = new Map<string, number>();
|
||||
|
||||
/** A statementless merchant purchase, for the non-fee signal. */
|
||||
async function purchase(date: string, merchant: string, amount: number) {
|
||||
await pool.query(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id, merchant_normalized, row_index)
|
||||
VALUES (NULL,$1,$2,$3,'debit',$4,$2,0)`,
|
||||
[date, merchant, amount, ownerId]
|
||||
);
|
||||
}
|
||||
|
||||
const find = <T extends { label: string }>(rows: T[], label: string) =>
|
||||
rows.find((r) => r.label === label);
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
({ getUpcomingAnnualCharges } = await import("../../lib/annual-charges"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM account_links");
|
||||
stmtCache = new Map();
|
||||
({ ownerId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("what it finds", () => {
|
||||
it("confirms a card annual fee that repeated a year later", async () => {
|
||||
await charge("American Express", "1000", "2025-06-10", "ANNUAL CARD FEE", 875);
|
||||
await charge("American Express", "1000", "2026-06-10", "ANNUAL CARD FEE", 1750);
|
||||
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL CARD FEE");
|
||||
expect(row?.confidence).toBe("confirmed");
|
||||
expect(row?.observations).toBe(2);
|
||||
// The amount is NOT averaged: this fee doubled, and the next one to plan for
|
||||
// is the latest, not the mean of a rise.
|
||||
expect(Number(row?.last_amount)).toBe(1750);
|
||||
expect(row?.next_due_estimate.startsWith("2027-06")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a fee seen only once when the bank names it annual", async () => {
|
||||
await charge("Westpac", "8032", "2025-10-21", "CARD FEE", 295);
|
||||
const row = find(await getUpcomingAnnualCharges(ownerId), "CARD FEE");
|
||||
// One charge cannot show a cadence, but the kind is not in doubt.
|
||||
expect(row?.confidence).toBe("likely");
|
||||
expect(row?.next_due_estimate).toBe("2026-10-21");
|
||||
});
|
||||
|
||||
it("finds an annual merchant charge with no fee wording, if the amount is stable", async () => {
|
||||
await purchase("2025-02-03", "AMAZON PRIME", 79);
|
||||
await purchase("2026-02-01", "AMAZON PRIME", 79);
|
||||
const row = find(await getUpcomingAnnualCharges(ownerId), "AMAZON PRIME");
|
||||
expect(row?.confidence).toBe("possible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it must NOT report", () => {
|
||||
it("ignores a monthly account fee", async () => {
|
||||
for (const d of ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"]) {
|
||||
await charge("ANZ", "5626", d, "ACCOUNT SERVICING FEE", 5);
|
||||
}
|
||||
expect(find(await getUpcomingAnnualCharges(ownerId), "ACCOUNT SERVICING FEE")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores per-transaction fees, which never repeat under one label", async () => {
|
||||
await charge("Wise Australia Pty Ltd.", "9001", "2026-05-20", "WISE CHARGES FOR: TRANSFER-2143509822", 30.67);
|
||||
await charge("Wise Australia Pty Ltd.", "9001", "2026-06-04", "WISE CHARGES FOR: TRANSFER-2171403032", 19.51);
|
||||
const rows = await getUpcomingAnnualCharges(ownerId);
|
||||
expect(rows.filter((r) => r.label.startsWith("WISE CHARGES"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores a merchant visited twice a year apart for wildly different amounts", async () => {
|
||||
// The exact shape that defeats a pure cadence test: Target, $30 then $198.
|
||||
await purchase("2025-07-21", "TARGET", 30);
|
||||
await purchase("2026-07-11", "TARGET", 198);
|
||||
expect(find(await getUpcomingAnnualCharges(ownerId), "TARGET")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a merchant seen only once", async () => {
|
||||
await purchase("2026-04-03", "UBER ONE MEMBERSHIP", 96);
|
||||
expect(find(await getUpcomingAnnualCharges(ownerId), "UBER ONE MEMBERSHIP")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("account identity and honesty about gaps", () => {
|
||||
it("treats a renamed account as one continuous fee series", async () => {
|
||||
// Citi Prestige -> NAB MyCard: same card, two bank names, two numbers.
|
||||
await charge("Citibank", "5258073330340253", "2024-12-13", "Annual Fee", 700);
|
||||
await charge("Citibank", "5258073330340253", "2025-11-13", "Annual Fee", 700);
|
||||
await pool.query(
|
||||
`INSERT INTO account_links (from_bank_name, from_account_key, to_bank_name, to_account_key, effective_date, reason)
|
||||
VALUES ('Citibank','5258073330340253','NAB','1015001000116227','2025-11-14','issuer_change')`
|
||||
);
|
||||
// A statement under the NEW identity, so the link has somewhere to resolve to.
|
||||
await charge("NAB", "1015001000116227", "2026-08-13", "Purchase", 29.99, "debit");
|
||||
|
||||
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL FEE");
|
||||
expect(row?.observations).toBe(2);
|
||||
expect(row?.confidence).toBe("confirmed");
|
||||
// Names the account's CURRENT bank -- the next fee comes from there, and
|
||||
// sending someone to the retired brand is the bug this link exists to stop.
|
||||
expect(row?.bank_name).toBe("NAB");
|
||||
});
|
||||
|
||||
it("marks a passed estimate as expected_not_seen rather than upcoming", async () => {
|
||||
await charge("HSBC", "0351", "2024-03-06", "ANNUAL CHARGE", 450);
|
||||
await charge("HSBC", "0351", "2025-03-06", "ANNUAL CHARGE", 450);
|
||||
const row = find(await getUpcomingAnnualCharges(ownerId), "ANNUAL CHARGE");
|
||||
expect(row?.status).toBe("expected_not_seen");
|
||||
// And it reports how stale the account is, which is what separates
|
||||
// "it stopped being charged" from "we stopped importing the statements".
|
||||
expect(row?.account_days_stale).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("sorts upcoming ahead of expected_not_seen", async () => {
|
||||
await charge("HSBC", "0351", "2024-03-06", "ANNUAL CHARGE", 450);
|
||||
await charge("HSBC", "0351", "2025-03-06", "ANNUAL CHARGE", 450);
|
||||
await charge("Westpac", "8032", "2026-08-01", "CARD FEE", 295);
|
||||
const rows = await getUpcomingAnnualCharges(ownerId);
|
||||
const firstMissing = rows.findIndex((r) => r.status === "expected_not_seen");
|
||||
const lastUpcoming = rows.map((r) => r.status).lastIndexOf("upcoming");
|
||||
expect(lastUpcoming).toBeLessThan(firstMissing);
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* The automatic pass over the reconcile queue.
|
||||
*
|
||||
* This exists because the wallet lane writes a transaction for every tap
|
||||
* (DECISIONS.md ING-11), adding ~63 rows a month to a queue already ~177 deep
|
||||
* and worked by hand. Hiding those rows instead is explicitly ruled out --
|
||||
* see the awaitsStatementLine() removal note in queries.ts.
|
||||
*
|
||||
* The cases that must NOT match matter more than the ones that must: this runs
|
||||
* unattended, and a wrong pairing merges one purchase's curation onto another
|
||||
* purchase's row. So most of what follows is about refusal.
|
||||
*/
|
||||
|
||||
const MACHINE_TOKEN = "test-wallet-token";
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let autoReconcile: typeof import("../../app/api/transactions/reconcile/route").PATCH;
|
||||
|
||||
/** Browser-session caller: no machine token, auth comes from the mocked session. */
|
||||
const userReq = (url = "http://x/api/transactions/reconcile") =>
|
||||
({ url, headers: { get: () => null } }) as never;
|
||||
|
||||
/** Scheduled caller: machine token, no session, sweeps every owner. */
|
||||
const machineReq = (url = "http://x/api/transactions/reconcile") =>
|
||||
({ url, headers: { get: (h: string) => (h === "x-ingest-token" ? MACHINE_TOKEN : null) } }) as never;
|
||||
|
||||
async function statementLine(
|
||||
amount: number,
|
||||
date: string,
|
||||
description: string,
|
||||
opts: { owner?: number; type?: string; fxAmount?: number; fxCode?: string } = {}
|
||||
) {
|
||||
const owner = opts.owner ?? ownerId;
|
||||
const st = await pool.query(
|
||||
// Account number varies by owner: uq_statement_identity is
|
||||
// (bank_name, account_number, billing_end_date), so two owners sharing one
|
||||
// account collide rather than modelling two real cards.
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ('s.pdf', $3, 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
|
||||
[date, owner, `1234-5678-9012-${String(owner).padStart(4, "0")}`]
|
||||
);
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(statement_id, transaction_date, description, amount, transaction_type, owner_id,
|
||||
foreign_currency_amount, foreign_currency_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[st.rows[0].id, date, description, amount, opts.type ?? "debit", owner,
|
||||
opts.fxAmount ?? null, opts.fxCode ?? null]
|
||||
);
|
||||
return t.rows[0].id as number;
|
||||
}
|
||||
|
||||
async function walletRow(
|
||||
amount: number,
|
||||
date: string,
|
||||
opts: { fxAmount?: number; fxCode?: string; paymentMethod?: string } = {}
|
||||
) {
|
||||
const id = await insertTransaction(pool, ownerId, {
|
||||
amount, transaction_date: date, description: "Wallet tap",
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE transactions
|
||||
SET foreign_currency_amount = $2, foreign_currency_code = $3, payment_method = $4
|
||||
WHERE id = $1`,
|
||||
[id, opts.fxAmount ?? null, opts.fxCode ?? null, opts.paymentMethod ?? null]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
const reconciledWith = async (id: number) =>
|
||||
(await pool.query(`SELECT reconciled_with_id FROM transactions WHERE id = $1`, [id]))
|
||||
.rows[0].reconciled_with_id as number | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.WALLET_INGEST_TOKEN = MACHINE_TOKEN;
|
||||
pool = createPool();
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
||||
}));
|
||||
({ PATCH: autoReconcile } = await import("../../app/api/transactions/reconcile/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("pairs it should apply", () => {
|
||||
it("matches an exact amount two days apart and moves the link", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
const stmt = await statementLine(13.5, "2026-08-15", "BREADWORLD BAKERY");
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("matches a foreign row on the NATIVE pair, not on the AUD amount", async () => {
|
||||
// The bank converts at its own rate, so the wallet row cannot predict the
|
||||
// AUD figure. It can only know THB 500. Deliberately give the statement a
|
||||
// wildly different AUD amount to prove `amount` is not what matched.
|
||||
const manual = await walletRow(999.99, "2026-08-13", { fxAmount: 500, fxCode: "THB" });
|
||||
const stmt = await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", {
|
||||
fxAmount: 500, fxCode: "THB",
|
||||
});
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("carries a SETTLED split across without resurrecting the debt", async () => {
|
||||
const manual = await walletRow(50, "2026-08-13");
|
||||
const stmt = await statementLine(50, "2026-08-14", "SHARED DINNER");
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled, settled_at)
|
||||
VALUES ($1, $2, 50, true, now())`,
|
||||
[manual, ownerId]
|
||||
);
|
||||
|
||||
expect((await autoReconcile(userReq())).status ?? 200).toBe(200);
|
||||
|
||||
const moved = await pool.query(
|
||||
`SELECT settled, settled_at FROM transaction_splits WHERE transaction_id = $1`, [stmt]
|
||||
);
|
||||
expect(moved.rowCount).toBe(1);
|
||||
// The whole point: a delete-and-recreate defaults `settled` to false, which
|
||||
// silently marks a paid debt owed again. Nothing errors when it regresses.
|
||||
expect(moved.rows[0].settled).toBe(true);
|
||||
expect(moved.rows[0].settled_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairs it must refuse", () => {
|
||||
it("refuses two identical amounts in the same window (ambiguous both ways)", async () => {
|
||||
// Two $13.50 bakery taps in one week is the ordinary case, not an edge one.
|
||||
const a = await walletRow(13.5, "2026-08-13");
|
||||
const b = await walletRow(13.5, "2026-08-14");
|
||||
await statementLine(13.5, "2026-08-14", "BREADWORLD ONE");
|
||||
await statementLine(13.5, "2026-08-15", "BREADWORLD TWO");
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(0);
|
||||
expect(await reconciledWith(a)).toBeNull();
|
||||
expect(await reconciledWith(b)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a near-miss amount — a person should look at that", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.55, "2026-08-14", "BREADWORLD BAKERY");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a match outside the day window", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.5, "2026-08-25", "BREADWORLD BAKERY");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a statement row already claimed by another manual row", async () => {
|
||||
const claimed = await statementLine(40, "2026-08-14", "ALREADY TAKEN");
|
||||
const first = await walletRow(40, "2026-08-13");
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $2 WHERE id = $1`, [first, claimed]);
|
||||
const second = await walletRow(40, "2026-08-14");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(second)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a credits-funded row — no card leg is ever coming", async () => {
|
||||
const manual = await walletRow(30, "2026-08-13", { paymentMethod: "credits" });
|
||||
await statementLine(30, "2026-08-14", "COINCIDENTAL AMOUNT");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("an AUD row against a foreign statement line", () => {
|
||||
it("DOES match on the AUD figure — this is what every real pair looks like", async () => {
|
||||
// Checked against live data: all 48 reconciliations a human has made are
|
||||
// this exact shape -- manual row with no currency, statement row carrying
|
||||
// EUR or CHF, paired on exact AUD. Overseas receipts and orders record what
|
||||
// was charged in AUD; only the wallet lane records natively. An earlier
|
||||
// version of this rule required both sides to be non-foreign, on the
|
||||
// reasoning that a currency mismatch is ambiguous. It matched 0 of the 48.
|
||||
const manual = await walletRow(22.85, "2026-08-13");
|
||||
const stmt = await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", {
|
||||
fxAmount: 500, fxCode: "THB",
|
||||
});
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("still refuses when a second candidate makes it ambiguous", async () => {
|
||||
// The coincidence this guards against -- a domestic tap whose amount equals
|
||||
// a foreign charge's AUD figure -- is caught by unique-both-ways, not by a
|
||||
// currency test.
|
||||
const manual = await walletRow(22.85, "2026-08-13");
|
||||
await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", { fxAmount: 500, fxCode: "THB" });
|
||||
await statementLine(22.85, "2026-08-13", "MELBOURNE CAFE");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dry run", () => {
|
||||
it("reports what it would do and writes nothing", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.5, "2026-08-14", "BREADWORLD BAKERY");
|
||||
|
||||
const body = await (await autoReconcile(userReq("http://x/api/transactions/reconcile?dry=1"))).json();
|
||||
expect(body.dry_run).toBe(true);
|
||||
expect(body.would_reconcile).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the scheduled sweep", () => {
|
||||
it("runs on the machine token with no session at all", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
const stmt = await statementLine(13.5, "2026-08-14", "BREADWORLD BAKERY");
|
||||
|
||||
const body = await (await autoReconcile(machineReq())).json();
|
||||
expect(body.reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("covers EVERY owner, not just one", async () => {
|
||||
// 169 of the 177 rows outstanding belong to the second participant, so a
|
||||
// sweep scoped to a single user would leave almost all of the queue alone.
|
||||
const other = (await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
|
||||
)).rows[0].id as number;
|
||||
|
||||
const mine = await walletRow(11, "2026-08-13");
|
||||
const theirTx = (await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description,
|
||||
amount, transaction_type, category, row_index)
|
||||
VALUES ($1, NULL, '2026-08-13', 'Theirs', 22, 'debit', 'other', 0) RETURNING id`, [other]
|
||||
)).rows[0].id as number;
|
||||
|
||||
await statementLine(11, "2026-08-14", "MINE");
|
||||
await statementLine(22, "2026-08-14", "THEIRS", { owner: other });
|
||||
|
||||
const body = await (await autoReconcile(machineReq())).json();
|
||||
expect(body.reconciled).toBe(2);
|
||||
expect(await reconciledWith(mine)).not.toBeNull();
|
||||
expect(await reconciledWith(theirTx)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a wrong machine token and falls through to session auth", async () => {
|
||||
const bad = ({ url: "http://x/", headers: { get: () => "not-the-token" } }) as never;
|
||||
// Session is mocked, so this still succeeds -- but as the session user only,
|
||||
// proving the bad token did not grant the all-owner sweep.
|
||||
const body = await (await autoReconcile(bad)).json();
|
||||
expect(body.reconciled).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Pool } from "pg";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createPool() {
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
}
|
||||
|
||||
// Replace the app's Prisma-based queryRaw with a direct pg call so that
|
||||
// tests don't depend on Prisma's singleton picking up the right DATABASE_URL.
|
||||
// Must be called BEFORE dynamically importing any module that uses @/lib/db.
|
||||
// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`.
|
||||
export function mockDbWithPool(p: Pool) {
|
||||
vi.resetModules(); // clear module cache so fresh imports pick up the mock
|
||||
vi.doMock("@/lib/db", () => ({
|
||||
queryRaw: async (sql: string, params: unknown[] = []) => {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows;
|
||||
},
|
||||
// Mirrors the real module: a mock that omits an export makes it `undefined`
|
||||
// at the call site, so any route using queryRow fails with a confusing
|
||||
// "not a function" rather than a query error.
|
||||
queryRow: async (sql: string, params: unknown[] = []) => {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows[0] ?? null;
|
||||
},
|
||||
// Mirrors withTransaction: one connection, BEGIN/COMMIT around the callback,
|
||||
// so pg_advisory_xact_lock actually holds across the statements inside it.
|
||||
withTransaction: async (fn: (q: unknown) => Promise<unknown>) => {
|
||||
const client = await p.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn({
|
||||
queryRaw: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows,
|
||||
queryRow: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows[0] ?? null,
|
||||
});
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
prisma: p,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to truncate anything that is not the test database.
|
||||
*
|
||||
* `.env.test` used to name the Postgres container by bridge IP, and Docker
|
||||
* reassigns those on recreation: 172.22.0.47 stopped being `postgres-personal`
|
||||
* and became `postgres-pantry`, so the suite spent a while pointing its
|
||||
* TRUNCATE at another app's database. It only failed safe because the
|
||||
* credentials happened not to match — had they matched, this would have wiped
|
||||
* pantry-app.
|
||||
*
|
||||
* `scripts/resolve-test-db.sh` (wired as `pretest:integration`) now re-derives
|
||||
* the DSN from the live container before every run, preferring the published
|
||||
* loopback port. This check stays anyway: running vitest directly bypasses the
|
||||
* pretest hook, and a stale `.env.test` would still be read.
|
||||
*
|
||||
* Checked once per process, before the first truncate.
|
||||
*/
|
||||
let targetVerified = false;
|
||||
async function assertTestDatabase(pool: Pool) {
|
||||
if (targetVerified) return;
|
||||
const { rows } = await pool.query<{ db: string }>(`SELECT current_database() AS db`);
|
||||
const db = rows[0]?.db;
|
||||
if (db !== "personal_test") {
|
||||
throw new Error(
|
||||
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
|
||||
`Re-resolve the DSN with \`bash scripts/resolve-test-db.sh\` (or just run ` +
|
||||
`\`npm run test:integration\`, which does it for you), and run ` +
|
||||
`\`npm run test:setup\` if personal_test does not exist yet.`
|
||||
);
|
||||
}
|
||||
targetVerified = true;
|
||||
}
|
||||
|
||||
/** Wipe all data tables and restart sequences between tests. */
|
||||
export async function resetDB(pool: Pool) {
|
||||
await assertTestDatabase(pool);
|
||||
await pool.query(`
|
||||
TRUNCATE
|
||||
-- The audit triggers (migration 0037) fire on every INSERT the suite makes.
|
||||
-- TRUNCATE does not fire row-level triggers, so nothing here is logged --
|
||||
-- but without this line the log is never cleared either, and the table
|
||||
-- grows by roughly a thousand rows per full run, forever.
|
||||
transaction_audit,
|
||||
split_payments,
|
||||
transaction_splits,
|
||||
transaction_tags,
|
||||
transaction_overrides,
|
||||
rule_apply_runs,
|
||||
rules,
|
||||
budgets,
|
||||
account_owner_mappings,
|
||||
transactions,
|
||||
statements,
|
||||
tags,
|
||||
trips,
|
||||
participants
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
/** Seed two participants and return their IDs. */
|
||||
export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[0]]
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[1]]
|
||||
);
|
||||
return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number };
|
||||
}
|
||||
|
||||
/** Insert a manual transaction (no statement) and return its id. */
|
||||
export async function insertTransaction(
|
||||
pool: Pool,
|
||||
ownerId: number,
|
||||
overrides: {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
category?: string;
|
||||
transaction_type?: string;
|
||||
transaction_date?: string;
|
||||
merchant_normalized?: string;
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||
[
|
||||
ownerId,
|
||||
// Post-cutover by default: a split on an older transaction never counts
|
||||
// towards a balance (ACTIVE_OBLIGATION), so a pre-cutover default would
|
||||
// make every balance fixture silently read zero.
|
||||
overrides.transaction_date ?? "2026-06-15",
|
||||
overrides.description ?? "Test transaction",
|
||||
overrides.amount ?? 100,
|
||||
overrides.transaction_type ?? "debit",
|
||||
overrides.category ?? "other",
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { readFileSync, mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants } from "./helpers";
|
||||
import type { ExtractedOrder } from "../../lib/intake-rules";
|
||||
|
||||
/**
|
||||
* The intake router against the real schema (plan §11 C1, C2, C5).
|
||||
*
|
||||
* The model is stubbed at the HTTP boundary — `fetch` to LiteLLM answers with a
|
||||
* canned extraction, `fetch` to pantry with a canned capture — so the PDF text
|
||||
* path, the persistence, the match and the writes all run for real. The two
|
||||
* PDFs are the user's own Uber Eats exports; the transactions seeded here are
|
||||
* shaped like production rows 6846 and 6857 (platform `ubereats`, `line_items`
|
||||
* empty, the email pipeline's booking of the same two orders).
|
||||
*/
|
||||
|
||||
const INTAKE = "test-intake-token";
|
||||
const RECEIPT = "test-receipt-token";
|
||||
const fixtures = resolve(__dirname, "../fixtures/intake");
|
||||
const BURGERTORY = readFileSync(resolve(fixtures, "ubereats-burgertory-2026-08-19.pdf"));
|
||||
const COLES = readFileSync(resolve(fixtures, "ubereats-coles-2026-08-18.pdf"));
|
||||
|
||||
const burgertory: ExtractedOrder = {
|
||||
merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] },
|
||||
{ quantity: 1, description: "Joel", unit_price: 14, line_total: 14, options: [] },
|
||||
],
|
||||
adjustments: [{ label: "Delivery Fee", amount: 5.49 }, { label: "Promotion", amount: -8 }],
|
||||
};
|
||||
const coles: ExtractedOrder = {
|
||||
merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 3, description: "Coca-Cola Zero Sugar Soft Drink (2L)", unit_price: 4.4, line_total: 13.2, options: [] },
|
||||
{ quantity: 1, description: "Coles RSPCA Approved Chicken Thigh Cutlets approx. 1.1kg", unit_price: 11, line_total: 12.76, options: ["Final 1.16 kg $11.00/kg"] },
|
||||
{ quantity: 1, description: "everything else", unit_price: 92.98, line_total: 92.98, options: [] },
|
||||
],
|
||||
adjustments: [],
|
||||
};
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let fileDir: string;
|
||||
let intakePOST: (req: NextRequest) => Promise<Response>;
|
||||
let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
let slackPOST: (req: NextRequest) => Promise<Response>;
|
||||
let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
|
||||
/** What the next LiteLLM call answers, and what the next pantry call answers. */
|
||||
let modelReply: ExtractedOrder | Error = burgertory;
|
||||
let pantryReply: { status: number; body: unknown } = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
const calls: { url: string; body: unknown }[] = [];
|
||||
|
||||
function stubFetch() {
|
||||
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/v1/chat/completions")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
if (modelReply instanceof Error) return new Response("boom", { status: 500 });
|
||||
return Response.json({ choices: [{ message: { content: JSON.stringify(modelReply) }, finish_reason: "stop" }] });
|
||||
}
|
||||
if (u.includes("/api/capture")) {
|
||||
const form = init?.body as FormData;
|
||||
calls.push({ url: u, body: { intake: JSON.parse(String(form.get("intake"))), files: form.getAll("image").map((f) => (f as File).name), headers: init?.headers } });
|
||||
return Response.json(pantryReply.body, { status: pantryReply.status });
|
||||
}
|
||||
if (u.includes("hooks.slack.com")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
return new Response("ok");
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
});
|
||||
}
|
||||
|
||||
const multipart = (fields: Record<string, string>, files: { name: string; type: string; bytes: Buffer }[], token: string | null = INTAKE) => {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
|
||||
for (const f of files) fd.append("file", new File([new Uint8Array(f.bytes)], f.name, { type: f.type }));
|
||||
return new Request("http://finance.test/api/intake", { method: "POST", headers: token ? { "x-ingest-token": token } : {}, body: fd }) as unknown as NextRequest;
|
||||
};
|
||||
const jsonReq = (body: unknown, token: string | null) =>
|
||||
({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body }) as unknown as NextRequest;
|
||||
const put = (id: number | string, body: unknown, token: string | null = RECEIPT) => lineItemsPUT(jsonReq(body, token), { params: Promise.resolve({ id: String(id) }) });
|
||||
const uuid = () => crypto.randomUUID();
|
||||
|
||||
/** A transaction shaped like the email pipeline's booking of an order, line_items empty. */
|
||||
async function seedOrder(over: { merchant: string; date: string; amount: number; platform?: string | null; owner?: number; withMeta?: boolean }) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'dining', $5, $5, 'credits', 0) RETURNING id`,
|
||||
[over.owner ?? ownerId, over.date, `Order - ${over.merchant}`, over.amount, over.merchant]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
if (over.withMeta !== false) {
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency)
|
||||
VALUES ($1, 'email', $2, $3, '[]'::jsonb, $4, $5, $6, 'AUD')`,
|
||||
[id, over.platform === undefined ? "ubereats" : over.platform, uuid(), over.merchant, over.amount, over.date]
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
/** A row shaped the way the wallet lane writes one: statementless, no metadata
|
||||
* at all, no platform, and named by the notification rather than the receipt. */
|
||||
async function seedWalletRow(over: { merchant: string; date: string; amount: number; owner?: number }) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, location, source, source_ref, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'other', $3, $3, 'card', 'Werribee, VIC', 'wallet', $5, 0) RETURNING id`,
|
||||
[over.owner ?? ownerId, over.date, over.merchant, over.amount, `evt:${Math.floor(over.amount * 100)}`]
|
||||
);
|
||||
return t.rows[0].id as number;
|
||||
}
|
||||
|
||||
/** What `components/order-details.tsx` would actually render for a transaction. */
|
||||
const orderPanel = async (txnId: number) =>
|
||||
(await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null;
|
||||
const metaRows = async () => (await pool.query(`SELECT id, platform, line_items FROM expense_metadata ORDER BY id`)).rows as { id: number; platform: string | null; line_items: { description: string }[] }[];
|
||||
const lineItemsOf = async (txnId: number) =>
|
||||
(await pool.query(`SELECT line_items FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1`, [txnId])).rows[0]?.line_items;
|
||||
const batch = async (id: string) => (await pool.query(`SELECT * FROM intake_batches WHERE id = $1`, [id])).rows[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.INTAKE_TOKEN = INTAKE;
|
||||
process.env.RECEIPT_INGEST_TOKEN = RECEIPT;
|
||||
process.env.ORDER_INGEST_TOKEN = "test-order-token";
|
||||
process.env.LITELLM_BASE_URL = "http://litellm.test";
|
||||
process.env.LITELLM_KEY = "k";
|
||||
process.env.PANTRY_BASE_URL = "http://pantry.test";
|
||||
process.env.FINANCE_PUBLIC_URL = "https://finance.test";
|
||||
fileDir = mkdtempSync(join(tmpdir(), "intake-test-"));
|
||||
process.env.INTAKE_FILE_DIR = fileDir;
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
// The Slack signature gate is exercised by slack-verify.test.ts; here it is
|
||||
// bypassed so the intake branch of the route is what is under test.
|
||||
vi.doMock("@/lib/slack-verify", () => ({
|
||||
verifySlackSignature: () => true,
|
||||
participantForSlackUser: () => 1,
|
||||
slackUserForParticipant: () => null,
|
||||
}));
|
||||
// The order-detail panel is the surface an enrichment has to appear on, so
|
||||
// the read is exercised beside the write rather than assumed.
|
||||
vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: 1, name: "Owner", email: "o@x" }) }));
|
||||
vi.doMock("@/lib/queries", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
canAccessTransactions: async () => true,
|
||||
}));
|
||||
({ POST: intakePOST } = await import("../../app/api/intake/route"));
|
||||
({ PUT: lineItemsPUT } = await import("../../app/api/transactions/[id]/line-items/route"));
|
||||
({ POST: slackPOST } = await import("../../app/api/slack/interactive/route"));
|
||||
({ GET: orderGET } = await import("../../app/api/transactions/[id]/order/route"));
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(fileDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query(`TRUNCATE intake_batches, intake_merchant_kinds`);
|
||||
({ ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]));
|
||||
await pool.query(`UPDATE participants SET email = 'bob@example.test' WHERE id = $1`, [otherId]);
|
||||
modelReply = burgertory;
|
||||
pantryReply = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
// ─── C2 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT /api/transactions/[id]/line-items (C2)", () => {
|
||||
const items = [{ qty: 1, description: "Michael", amount: 14, options: ["Milk Bun"] }, { qty: 1, description: "Joel", amount: 14 }];
|
||||
|
||||
it("fails closed: no token, wrong token", async () => {
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, null)).status).toBe(401);
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, "nope")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts either token", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "pantry", replace: true }, RECEIPT)).status).toBe(200);
|
||||
expect((await put(id, { line_items: items, source: "intake", replace: true }, INTAKE)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("404 when the transaction does not exist — it never creates one", async () => {
|
||||
const res = await put(999999, { line_items: items, source: "intake", replace: true });
|
||||
expect(res.status).toBe(404);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a body that is not a replace, a bad source, or nameless items", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "intake" })).status).toBe(400);
|
||||
expect((await put(id, { line_items: items, source: "n8n", replace: true })).status).toBe(400);
|
||||
expect((await put(id, { line_items: [{ qty: 1 }], source: "intake", replace: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it("idempotent replace: returns previous + new, and a second identical call is a no-op", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = await (await put(id, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual([]);
|
||||
expect(first.line_items).toHaveLength(2);
|
||||
expect(first.created_metadata).toBe(false);
|
||||
expect(await lineItemsOf(id)).toHaveLength(2);
|
||||
|
||||
const corrected = [{ qty: 1, description: "Michael (corrected)", amount: 14 }];
|
||||
const second = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(second.previous_line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await lineItemsOf(id)).toEqual(corrected);
|
||||
|
||||
const third = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(third.previous_line_items).toEqual(corrected);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("undo shape: PUT previous_line_items back restores the row", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const r = await (await put(id, { line_items: items, source: "pantry", replace: true })).json();
|
||||
await put(id, { line_items: r.previous_line_items, source: "pantry", replace: true });
|
||||
expect(await lineItemsOf(id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates the expense_metadata row when the transaction has none, with source and platform", async () => {
|
||||
const id = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||
const res = await (await put(id, { line_items: items, source: "intake", platform: "ubereats", batch_id: "b1", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(true);
|
||||
const em = (await pool.query(`SELECT source, platform, order_reference, merchant_normalized, amount::text FROM expense_metadata WHERE transaction_id = $1`, [id])).rows[0];
|
||||
expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" });
|
||||
});
|
||||
|
||||
it("the undo baseline is recorded ONCE per batch: a retry gets the original, not its own write", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const original = [{ qty: 1, description: "what the order already had", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(original)]);
|
||||
const id = uuid();
|
||||
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
|
||||
|
||||
const first = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual(original);
|
||||
// Pantry re-calls on every confirm and after a timeout. Before, the retry
|
||||
// was handed its OWN write back, so undo restored the thing it undid.
|
||||
const retry = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
|
||||
expect(retry.previous_line_items).toEqual(original);
|
||||
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual(original);
|
||||
|
||||
await put(txn, { line_items: retry.previous_line_items, source: "pantry", batch_id: id, replace: true });
|
||||
expect(await lineItemsOf(txn)).toEqual(original);
|
||||
});
|
||||
|
||||
it("without a batch id the baseline is still the row as read — nothing else changes", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual([]);
|
||||
const second = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
|
||||
expect(second.previous_line_items).toEqual(items);
|
||||
});
|
||||
|
||||
it("a pinned row that no longer exists falls back to the live one, rather than reporting a phantom write", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const live = (await pool.query(`SELECT id FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].id as number;
|
||||
const { replaceLineItems } = await import("../../lib/line-items");
|
||||
const r = await replaceLineItems(txn, items, { source: "intake", metadataId: live + 999 });
|
||||
expect(r.metadata_id).toBe(live);
|
||||
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("two writes racing to create the metadata row both resolve to the same one", async () => {
|
||||
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||
const both = await Promise.all([
|
||||
put(txn, { line_items: items, source: "intake", replace: true }),
|
||||
put(txn, { line_items: items, source: "intake", replace: true }),
|
||||
]);
|
||||
expect(both.map((r) => r.status)).toEqual([200, 200]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("the create path records an undo baseline too, before it writes", async () => {
|
||||
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||
const id = uuid();
|
||||
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
|
||||
await put(txn, { line_items: items, source: "intake", batch_id: id, currency: "AUD", replace: true });
|
||||
// [] rather than NULL: there was nothing there, and "nothing" is the state
|
||||
// undo has to be able to restore.
|
||||
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual([]);
|
||||
});
|
||||
|
||||
it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => {
|
||||
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||
// Inserted first: the delivery receipt pointing at the statement line.
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
|
||||
[t, uuid()]
|
||||
);
|
||||
// Inserted second: a row the transaction owns. An unordered LIMIT 1 reads
|
||||
// the first one, the write takes the second, and the enrichment reports
|
||||
// success against a row nothing renders.
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'pantry', $2, '[]'::jsonb)`,
|
||||
[t, uuid()]
|
||||
);
|
||||
const written = await (await put(t, { line_items: items, source: "intake", replace: true })).json();
|
||||
const panel = await orderPanel(t);
|
||||
expect(panel?.line_items).toHaveLength(2);
|
||||
expect((await metaRows()).find((r) => r.id === written.metadata_id)!.line_items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("finds the row through matched_transaction_id — a card-settled order has only that", async () => {
|
||||
const statementLine = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
|
||||
[statementLine, uuid()]
|
||||
);
|
||||
const res = await (await put(statementLine, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(false);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
expect(await lineItemsOf(statementLine)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C1 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/intake — gates", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("fails closed without INTAKE_TOKEN match", async () => {
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], null))).status).toBe(401);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], "nope"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("400 on a bad source, no files, too many files, a non-uuid batch_id", async () => {
|
||||
expect((await intakePOST(multipart({ source: "menulog" }, [pdf]))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, []))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, Array(7).fill(pdf)))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: "not-a-uuid" }, [pdf]))).status).toBe(400);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM intake_batches`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("declines a file type it cannot read, after persisting the batch", async () => {
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "clip.mp4", type: "video/mp4", bytes: Buffer.from("x") }]));
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("unsupported");
|
||||
expect(body.text).toMatch(/not this/);
|
||||
expect((await batch(id)).status).toBe("unsupported");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// 2026-08-22, live batch 3d2ffbe8…: an iOS Shortcut coerced the shared photo
|
||||
// to text, so the whole upload was the five characters `Image`. The model was
|
||||
// asked anyway and returned Burgertory (Braybrook), two "The Pride" lines and
|
||||
// a $41.15 total — none of which existed. Only the absent date kept it out of
|
||||
// the matcher, and the invented header was stored on the batch as
|
||||
// `needs_header`. The model must never be asked.
|
||||
it("a text part that cannot be a receipt is declined BEFORE any model call", async () => {
|
||||
const id = uuid();
|
||||
modelReply = { ...burgertory, merchant: "Burgertory (Braybrook)", order_date: null, total: 41.15 };
|
||||
const res = await intakePOST(
|
||||
multipart({ source: "other", batch_id: id }, [{ name: "shared.txt", type: "text/plain", bytes: Buffer.from("Image") }])
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ kind: "unsupported", status: "unsupported" });
|
||||
expect(body.text).toMatch(/single word/);
|
||||
expect(body.text).toMatch(/"Image"/);
|
||||
// The model was never asked — the only thing that makes confabulation impossible.
|
||||
expect(calls).toHaveLength(0);
|
||||
const row = await batch(id);
|
||||
expect(row.status).toBe("unsupported");
|
||||
expect(row.kind).toBe("unsupported");
|
||||
expect(row.header).toBeNull();
|
||||
expect(row.items).toBeNull();
|
||||
expect(row.transaction_id).toBeNull();
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("a junk text part beside a real receipt does not sink the batch", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const body = await (
|
||||
await intakePOST(
|
||||
multipart({ source: "uber_eats" }, [
|
||||
{ name: "shared.txt", type: "text/plain", bytes: Buffer.from("Image") },
|
||||
{ name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY },
|
||||
])
|
||||
)
|
||||
).json();
|
||||
expect(body.status).toBe("enriched");
|
||||
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("an order extracted from an unreadable PDF is unsupported, not needs_header", async () => {
|
||||
const id = uuid();
|
||||
// A PDF that renders to a scrap of junk still reaches the model — the
|
||||
// pre-check only inspects text parts. `confabulationReason` is the belt.
|
||||
vi.spyOn(await import("../../lib/intake-extract"), "extractPdfText").mockResolvedValueOnce("Image");
|
||||
modelReply = { ...burgertory, merchant: "Burgertory (Braybrook)" };
|
||||
const body = await (
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "x.pdf", type: "application/pdf", bytes: BURGERTORY }]))
|
||||
).json();
|
||||
expect(body).toMatchObject({ kind: "unsupported", status: "unsupported" });
|
||||
expect(body.text).toMatch(/nothing shared could be read/);
|
||||
const row = await batch(id);
|
||||
expect(row.status).toBe("unsupported");
|
||||
expect(row.header).toBeNull();
|
||||
expect(row.items).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — food (enrich vs candidates)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("food + exact (platform, merchant, date, total) match → enriches the existing order, one model call, PDF as text", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toMatchObject({ kind: "food", status: "enriched", transaction_id: txn, batch_id: id });
|
||||
expect(body.text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("https://finance.test/transactions?q=Burgertory");
|
||||
|
||||
const stored = await lineItemsOf(txn);
|
||||
expect(stored.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(stored[0].options).toEqual(["Milk Bun"]);
|
||||
|
||||
// one joint call, the PDF's text in it (not an image)
|
||||
expect(calls).toHaveLength(1);
|
||||
const content = calls[0].body as { messages: { content: { type: string; text?: string }[] }[]; model: string; temperature: number; response_format: { type: string } };
|
||||
expect(content.model).toBe("cloud-budget");
|
||||
expect(content.temperature).toBe(0);
|
||||
expect(content.response_format.type).toBe("json_schema");
|
||||
expect(content.messages[0].content.some((c) => c.type === "image_url")).toBe(false);
|
||||
expect(content.messages[0].content.some((c) => c.text?.includes("1 Michael $14.00"))).toBe(true);
|
||||
|
||||
const row = await batch(id);
|
||||
expect(row).toMatchObject({ source: "uber_eats", shared_by: "bob@example.test", kind: "food", status: "enriched", transaction_id: txn });
|
||||
expect(row.header).toMatchObject({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, platform: "ubereats" });
|
||||
expect(row.resolved_at).not.toBeNull();
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match — but it IS offered", async () => {
|
||||
const doordash = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
// The candidates card is a human choice, not a decision. The platform is one
|
||||
// of the things that may be wrong, so it must not narrow the list — a
|
||||
// platform-filtered card showed nothing at all here.
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([doordash]);
|
||||
});
|
||||
|
||||
it("a NULL platform is 'no platform', not a wildcard — an in-store share does not overwrite the delivery order", async () => {
|
||||
await pool.query(`INSERT INTO intake_merchant_kinds (merchant_key, kind) VALUES ('burgertory werribee', 'food')`);
|
||||
const delivery = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "ubereats" });
|
||||
const already = [{ qty: 1, description: "what the delivery order already had", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [delivery, JSON.stringify(already)]);
|
||||
|
||||
// `other` implies no platform; the delivery row carries one, so it is not
|
||||
// the match — it is only a candidate.
|
||||
const body = await (await intakePOST(multipart({ source: "other" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([delivery]);
|
||||
expect(await lineItemsOf(delivery)).toEqual(already);
|
||||
});
|
||||
|
||||
it("a reconciled manual row is neither matched nor offered — EXCLUDE_RECONCILED_SOURCE, as everywhere else", async () => {
|
||||
const live = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const reconciled = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, reconciled]);
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, near]);
|
||||
|
||||
// Two rows at the exact key would have read as an ambiguity; one of them is
|
||||
// the same spend counted twice.
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ status: "enriched", transaction_id: live });
|
||||
});
|
||||
|
||||
it("the write lands on the row the MATCH resolved through, not on whichever row sorts first", async () => {
|
||||
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||
// The statement line owns a receipt row of its own (a pantry confirm) …
|
||||
const grocery = [{ qty: 1, description: "a grocery shop", amount: 22.4 }];
|
||||
const own = (await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items) VALUES ($1, 'pantry', $2, 'Woolworths', $3::jsonb) RETURNING id`,
|
||||
[t, uuid(), JSON.stringify(grocery)]
|
||||
)).rows[0].id as number;
|
||||
// … and is also the card leg of the Uber Eats order being enriched.
|
||||
const delivery = (await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, merchant_normalized, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, 'Burgertory (Werribee)', '[]'::jsonb) RETURNING id`,
|
||||
[t, uuid()]
|
||||
)).rows[0].id as number;
|
||||
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ status: "enriched", transaction_id: t });
|
||||
const rows = await metaRows();
|
||||
expect(rows.find((r) => r.id === delivery)!.line_items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(rows.find((r) => r.id === own)!.line_items).toEqual(grocery);
|
||||
});
|
||||
|
||||
it("an upload with no prices never replaces a receipt that already has items", async () => {
|
||||
const priceless = { ...burgertory, subtotal: null, total: 22.4, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]);
|
||||
|
||||
modelReply = priceless;
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "held", status: "held" });
|
||||
expect(body.text).toMatch(/left the existing items alone/);
|
||||
expect(await lineItemsOf(txn)).toEqual(already);
|
||||
|
||||
// …but an EMPTY receipt is exactly what this slice exists to fill.
|
||||
const empty = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-20", amount: 22.4 });
|
||||
modelReply = { ...priceless, order_date: "2026-08-20" };
|
||||
const second = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(second).toMatchObject({ status: "enriched", transaction_id: empty });
|
||||
expect((await lineItemsOf(empty)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
});
|
||||
|
||||
it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-10", amount: 22.4 }); // too far
|
||||
await seedOrder({ merchant: "Mad Mex", date: "2026-08-19", amount: 22.4 }); // wrong merchant
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([near]);
|
||||
const blocks = JSON.stringify(body.slack_blocks);
|
||||
expect(blocks).toContain('"action_id":"intake_use_candidate"');
|
||||
expect(blocks).toContain('"action_id":"intake_create"');
|
||||
expect(blocks).toContain(JSON.stringify(JSON.stringify({ batch_id: id, transaction_id: near })));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(3);
|
||||
expect(await lineItemsOf(near)).toEqual([]);
|
||||
expect((await batch(id)).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("two exact hits is an ambiguity, not a coin toss", async () => {
|
||||
const a = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const b = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id).sort()).toEqual([a, b].sort());
|
||||
});
|
||||
|
||||
it("missing header (no total) → needs_header, nothing written but the batch row", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, total: null };
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("needs_header");
|
||||
// Asserts BOTH halves of needsHeaderCard(): that it names what is actually
|
||||
// missing (total, not a blanket "total or date"), and that a uber_eats
|
||||
// source gets the share-more-of-the-screen advice rather than the in-store
|
||||
// Share-button advice. 258fe3b changed this text and updated the unit tests
|
||||
// but not this one.
|
||||
expect(body.text).toMatch(/I can see items but no total/);
|
||||
expect(body.text).toMatch(/share the part of the screen that shows it/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id)).status).toBe("needs_header");
|
||||
});
|
||||
|
||||
it("items that do not add up → held, nothing written", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, items: [burgertory.items[0]] };
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "held", status: "held" });
|
||||
expect(body.text).toMatch(/is a shot missing/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
});
|
||||
|
||||
it("a model failure is a 502 with the batch marked error, not a silent 200", async () => {
|
||||
modelReply = new Error("down");
|
||||
const res = await intakePOST(multipart({ source: "uber_eats" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
|
||||
it("re-share of the same file after it was handled → duplicate with a pointer", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
calls.length = 0;
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of).toEqual({ batch_id: first, transaction_id: txn, capture_id: null });
|
||||
expect(body.text).toMatch(/Already handled/);
|
||||
expect(calls).toHaveLength(0); // no model call spent on a known file
|
||||
});
|
||||
|
||||
it("re-share of the same order under a different file (screenshot after PDF) → duplicate by header", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [{ name: "IMG_1.png", type: "image/png", bytes: Buffer.from("fake-png") }]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of.transaction_id).toBe(txn);
|
||||
});
|
||||
|
||||
it("a repeated batch_id is refused, not re-run", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — grocery → pantry", () => {
|
||||
const pdf = { name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES };
|
||||
|
||||
it("grocery + match → the ORIGINAL file goes to pantry with the target transaction and action enrich; finance is NOT touched", async () => {
|
||||
modelReply = coles;
|
||||
const txn = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74 });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
expect(body.text).toBe("Coles (Wyndham Vale) $85.74 → pantry board (3 items) — finance updates on confirm");
|
||||
|
||||
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown>; files: string[]; headers: Record<string, string> };
|
||||
expect(pantryCall.headers["x-ingest-token"]).toBe(RECEIPT);
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toEqual({ batch_id: id, platform: "ubereats", origin: "intake", shared_by: "bob@example.test", finance_target_transaction_id: txn, finance_action: "enrich" });
|
||||
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
});
|
||||
|
||||
it("an in-store tap does not hand pantry the delivery order as its finance target", async () => {
|
||||
modelReply = coles;
|
||||
// The Uber Eats Coles order the email pipeline already booked.
|
||||
const delivery = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: "ubereats" });
|
||||
// The same shop, same day, same money — but shared from the Coles app, so
|
||||
// it carries no platform and is a different purchase.
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown> };
|
||||
expect(pantryCall.intake).toMatchObject({ finance_action: "create", finance_target_transaction_id: null, platform: null });
|
||||
expect(await lineItemsOf(delivery)).toEqual([]);
|
||||
});
|
||||
|
||||
it("grocery + no match → pantry with finance_action create (ING-10 as today)", async () => {
|
||||
modelReply = coles;
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
expect(body.text).toMatch(/finance books it on confirm/);
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown> };
|
||||
expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" });
|
||||
});
|
||||
|
||||
it("pantry saying the shop is already on its board is SHOWN, not swallowed", async () => {
|
||||
// Pantry accepts a second capture of one receipt on purpose (the first may
|
||||
// have been a bad crop) and reports it rather than refusing. Dropping that
|
||||
// field turned "reported" into silence — and a duplicate nobody sees is a
|
||||
// duplicate that gets confirmed, which counts the same groceries twice
|
||||
// because pantry's addItem accumulates. Seen live on captures 66/69.
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 200, body: { captureId: 902, items: [1, 2, 3], alreadyCaptured: { captureId: 66, confirmed: true } } };
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
expect(body.text).toMatch(/Already on the pantry board/);
|
||||
expect(body.text).toMatch(/capture 66 \(already confirmed\)/);
|
||||
expect(body.text).toMatch(/capture 902/);
|
||||
});
|
||||
|
||||
it("two ledger rows for one shop are reported, not treated as no match", async () => {
|
||||
// Ambiguity is not a miss. This is the exact state that produced the
|
||||
// 2026-08-18 Woolworths double-book: the shop was in the ledger twice, the
|
||||
// matcher returned nothing, and the card said finance would book it.
|
||||
modelReply = coles;
|
||||
await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: null });
|
||||
await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: null });
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
expect(body.text).toMatch(/found more than one row it could be/);
|
||||
});
|
||||
|
||||
it("pantry 409 → duplicate", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } };
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "duplicate", capture_id: 77 });
|
||||
});
|
||||
|
||||
it("pantry down → 502 error, batch marked error", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 500, body: { error: "nope" } };
|
||||
const res = await intakePOST(multipart({ source: "coles" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C5 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const slackPress = (action_id: string, value: unknown) => {
|
||||
const payload = { type: "block_actions", user: { id: "U1", name: "alice" }, response_url: "https://hooks.slack.com/r/1", actions: [{ action_id, value: JSON.stringify(value) }] };
|
||||
const raw = new URLSearchParams({ payload: JSON.stringify(payload) }).toString();
|
||||
return { headers: { get: (h: string) => (h === "x-ingest-token" ? "test-order-token" : "x") }, text: async () => raw } as unknown as NextRequest;
|
||||
};
|
||||
const lastSlackUpdate = () => calls.filter((c) => c.url.includes("hooks.slack.com")).at(-1)?.body as { replace_original?: boolean; text: string; blocks?: unknown[] };
|
||||
|
||||
describe("Slack taps (C5)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("intake_use_candidate enriches the chosen transaction and replaces the card", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
const res = await slackPOST(slackPress("intake_use_candidate", { batch_id: id, transaction_id: near }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await lineItemsOf(near)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: near });
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: true, text: "Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓" });
|
||||
});
|
||||
|
||||
it("intake_create creates the transaction ING-10-style: manual, owner from shared_by, items attached; a second press is a no-op", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
const t = (await pool.query(`SELECT statement_id, owner_id, description, amount::text, category, transaction_date::text, source, source_ref FROM transactions`)).rows;
|
||||
expect(t).toEqual([{ statement_id: null, owner_id: otherId, description: "Order - Burgertory (Werribee)", amount: "22.40", category: "dining", transaction_date: "2026-08-19", source: "intake", source_ref: id }]);
|
||||
const em = (await pool.query(`SELECT source, platform, line_items FROM expense_metadata`)).rows[0];
|
||||
expect(em.source).toBe("intake");
|
||||
expect(em.platform).toBe("ubereats");
|
||||
expect(em.line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Created a \$22\.40 Burgertory \(Werribee\) transaction with 2 items/);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("intake_create re-runs the match: an order booked between the card and the tap is enriched, not duplicated", async () => {
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
|
||||
// The email/order bridge books the same purchase while the card sits in
|
||||
// Slack. The card's "no match" is minutes stale by the time it is pressed.
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
expect((await lineItemsOf(txn)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
|
||||
});
|
||||
|
||||
it("intake_create adopts the wallet row for the same tap instead of booking it twice", async () => {
|
||||
// The normal arrival order: the notification lands within seconds of the
|
||||
// tap, the screenshot is shared later. matchTransaction cannot see that row
|
||||
// — its key includes the platform and a notification has none — so before
|
||||
// this, Create booked the purchase a second time.
|
||||
const wallet = await seedWalletRow({ merchant: "Burgertory", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: wallet });
|
||||
expect((await lineItemsOf(wallet)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
// The suburb the notification supplied is the one thing nothing else knows.
|
||||
expect((await pool.query(`SELECT location FROM transactions WHERE id = $1`, [wallet])).rows[0].location).toBe("Werribee, VIC");
|
||||
});
|
||||
|
||||
it("intake_create still creates when a same-amount row already carries a receipt", async () => {
|
||||
// A row that another source fully described is a DIFFERENT purchase that
|
||||
// happens to share an amount — adopting it would move one order's items
|
||||
// onto another order's row.
|
||||
const other = await seedOrder({ merchant: "Someone Else", date: "2026-08-19", amount: 22.4, platform: null });
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [other, JSON.stringify([{ description: "theirs", qty: 1, amount: 22.4 }])]);
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(2);
|
||||
});
|
||||
|
||||
it("two pending cards for one order book it ONCE", async () => {
|
||||
const a = uuid();
|
||||
const b = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf]));
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
|
||||
expect((await batch(a)).status).toBe("candidates");
|
||||
expect((await batch(b)).status).toBe("candidates");
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: a }));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: b }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect((await batch(b)).transaction_id).toBe((await batch(a)).transaction_id);
|
||||
});
|
||||
|
||||
it("a sibling already resolved to the pantry board makes Create a duplicate, not a second row", async () => {
|
||||
// Two shares of one receipt, resolved out of order: nothing the second card
|
||||
// can match on, because the first one never produced a transaction.
|
||||
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
|
||||
const a = uuid();
|
||||
const b = uuid();
|
||||
await intakePOST(multipart({ source: "other", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
|
||||
await intakePOST(multipart({ source: "other", batch_id: a }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
|
||||
expect((await batch(a)).status).toBe("ask");
|
||||
|
||||
await slackPOST(slackPress("intake_grocery", { batch_id: a }));
|
||||
expect((await batch(a)).status).toBe("forwarded_to_pantry");
|
||||
|
||||
await slackPOST(slackPress("intake_meal", { batch_id: b }));
|
||||
expect((await batch(b)).status).toBe("candidates");
|
||||
await slackPOST(slackPress("intake_create", { batch_id: b }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
expect(await batch(b)).toMatchObject({ status: "duplicate", capture_id: 901 });
|
||||
expect(lastSlackUpdate().text).toMatch(/^Already handled/);
|
||||
});
|
||||
|
||||
it("two taps that land together still book one transaction, and neither reports an error", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: id })), slackPOST(slackPress("intake_create", { batch_id: id }))]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
const texts = calls.filter((c) => c.url.includes("hooks.slack.com")).map((c) => (c.body as { text: string }).text);
|
||||
expect(texts.every((t) => !/Couldn't process/.test(t))).toBe(true);
|
||||
});
|
||||
|
||||
it("a foreign receipt is booked in its own currency, with no invented AUD figure", async () => {
|
||||
modelReply = { ...burgertory, currency: "USD" };
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0];
|
||||
expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" });
|
||||
expect(lastSlackUpdate().text).toMatch(/USD 22\.40/);
|
||||
// OrderDetails prefers expense_metadata.currency, so a hard-coded 'AUD'
|
||||
// here printed "$" on a USD order's receipt panel.
|
||||
expect((await pool.query(`SELECT currency FROM expense_metadata`)).rows[0].currency).toBe("USD");
|
||||
});
|
||||
|
||||
it("the hold applies to the Create tap's re-match, not only to the upload path", async () => {
|
||||
modelReply = { ...burgertory, subtotal: null, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
|
||||
// Booked WITH its items between the card and the tap. The re-match walked
|
||||
// past the guard routeFood applies, so the same upload destroyed the same
|
||||
// receipt depending on which button reached it.
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect(await lineItemsOf(txn)).toEqual(already);
|
||||
expect(await batch(id)).toMatchObject({ status: "held" });
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("two DIFFERENT batches for one purchase, tapped together, book it once", async () => {
|
||||
const a = uuid();
|
||||
const b = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf]));
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
|
||||
// Their source_refs differ, so ON CONFLICT cannot save this; both taps
|
||||
// clear their match and duplicate checks before either inserts.
|
||||
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: a })), slackPOST(slackPress("intake_create", { batch_id: b }))]);
|
||||
const rows = (await pool.query(`SELECT id FROM transactions`)).rows;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect((await batch(a)).transaction_id).toBe(rows[0].id);
|
||||
expect((await batch(b)).transaction_id).toBe(rows[0].id);
|
||||
});
|
||||
|
||||
it("unknown shared_by falls back to the default owner — never a NULL owner", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", shared_by: "nobody@example.test", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT owner_id FROM transactions`)).rows[0].owner_id).toBe(1);
|
||||
});
|
||||
|
||||
it("`other` + unrecognised merchant asks; intake_meal routes it as food and remembers the merchant", async () => {
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall" };
|
||||
const txn = await seedOrder({ merchant: "Romeo's Food Hall", date: "2026-08-19", amount: 22.4, platform: null });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "other", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "ask", status: "ask" });
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("intake_grocery");
|
||||
|
||||
await slackPOST(slackPress("intake_meal", { batch_id: id }));
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds WHERE merchant_key = 'romeo s food hall'`)).rows[0].kind).toBe("food");
|
||||
|
||||
// The answer sticks: the next share of that merchant does not ask.
|
||||
const again = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG.png", type: "image/png", bytes: Buffer.from("png2") }]))).json();
|
||||
expect(again.status).toBe("duplicate"); // same order — but it was routed, not asked
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall", order_date: "2026-08-20" };
|
||||
const next = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG3.png", type: "image/png", bytes: Buffer.from("png3") }]))).json();
|
||||
expect(next).toMatchObject({ kind: "food", status: "candidates" });
|
||||
});
|
||||
|
||||
it("intake_grocery forwards the stored original files to pantry", async () => {
|
||||
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "other", batch_id: id }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
|
||||
expect((await batch(id)).status).toBe("ask");
|
||||
await slackPOST(slackPress("intake_grocery", { batch_id: id }));
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown>; files: string[] };
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toMatchObject({ batch_id: id, finance_action: "create" });
|
||||
expect(await batch(id)).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901 });
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds`)).rows[0].kind).toBe("grocery");
|
||||
});
|
||||
|
||||
it("a tap on a resolved batch returns its card and writes nothing; an unknown batch says so", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: uuid() }));
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: false, text: "I can't find that upload any more." });
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Manually resolving a duplicate the automation cannot see.
|
||||
*
|
||||
* Until this route existed there was no way to merge or remove a transaction at
|
||||
* all: `superseded_by_id` was read everywhere and written nowhere in
|
||||
* application code, having been set exactly once by migration 0023. The only
|
||||
* merge path was the reconcile queue, whose candidate list is bounded by an
|
||||
* amount band -- so SP PEAKDESIGN (notified $42.44, charged $43.71, the card's
|
||||
* 3% foreign transaction fee) had no offerable twin.
|
||||
*
|
||||
* The refusals matter more than the happy paths, exactly as in
|
||||
* auto-reconcile.test.ts: this writes to columns that hide a row from every
|
||||
* figure, so a wrong call does not error, it quietly stops counting money.
|
||||
*/
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let merge: typeof import("../../app/api/transactions/[id]/merge/route").POST;
|
||||
|
||||
const req = (body: unknown) => ({ json: async () => body, headers: { get: () => null } }) as never;
|
||||
const call = (id: number, body: unknown) => merge(req(body), { params: Promise.resolve({ id: String(id) }) });
|
||||
|
||||
// uq_statement_identity is (bank_name, account_number, billing_end_date), so
|
||||
// every statement in a test needs its own account or the second insert collides.
|
||||
let acctSeq = 0;
|
||||
|
||||
async function statementLine(amount: number, date: string, description: string) {
|
||||
const st = await pool.query(
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ('s.pdf', $3, 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
|
||||
[date, ownerId, `4444-0000-0000-${String(1000 + acctSeq++)}`]
|
||||
);
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
|
||||
VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
|
||||
[st.rows[0].id, date, description, amount, ownerId]
|
||||
);
|
||||
return t.rows[0].id as number;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
||||
}));
|
||||
({ POST: merge } = await import("../../app/api/transactions/[id]/merge/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId, otherId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("reconcile mode", () => {
|
||||
it("folds a wallet row into its statement twin even though the amounts differ by the FX fee", async () => {
|
||||
const wallet = await insertTransaction(pool, ownerId, {
|
||||
description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16",
|
||||
});
|
||||
const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA");
|
||||
|
||||
const res = await call(wallet, { other_id: stmt, mode: "reconcile" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
|
||||
const r = await pool.query(`SELECT reconciled_with_id FROM transactions WHERE id = $1`, [wallet]);
|
||||
expect(r.rows[0].reconciled_with_id).toBe(stmt);
|
||||
});
|
||||
|
||||
it("refuses when the target is not a statement row", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 10 });
|
||||
const b = await insertTransaction(pool, ownerId, { amount: 10 });
|
||||
const res = await call(a, { other_id: b, mode: "reconcile" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses when the source is itself a statement row", async () => {
|
||||
const s1 = await statementLine(10, "2026-08-01", "A");
|
||||
const s2 = await statementLine(10, "2026-08-02", "B");
|
||||
const res = await call(s1, { other_id: s2, mode: "reconcile" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersede mode", () => {
|
||||
it("carries splits onto the survivor before hiding the loser", async () => {
|
||||
// The whole reason supersede moves curation: `superseded_by_id` removes the
|
||||
// row from every figure, so a split left behind stops counting silently and
|
||||
// a settled debt reappears as owed.
|
||||
const loser = await insertTransaction(pool, ownerId, { amount: 80, description: "DUP" });
|
||||
const survivor = await insertTransaction(pool, ownerId, { amount: 80, description: "KEEP" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1,$2,50,true), ($1,$3,50,false)`,
|
||||
[loser, ownerId, otherId]
|
||||
);
|
||||
|
||||
const res = await call(loser, { other_id: survivor, mode: "supersede" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
|
||||
const onLoser = await pool.query(`SELECT count(*)::int c FROM transaction_splits WHERE transaction_id=$1`, [loser]);
|
||||
const onSurvivor = await pool.query(
|
||||
`SELECT participant_id, share_percent, settled FROM transaction_splits
|
||||
WHERE transaction_id=$1 ORDER BY participant_id`, [survivor]
|
||||
);
|
||||
expect(onLoser.rows[0].c).toBe(0);
|
||||
expect(onSurvivor.rows).toHaveLength(2);
|
||||
// settled must survive the move -- a delete-and-recreate defaults it to false.
|
||||
expect(onSurvivor.rows.map((r) => r.settled)).toContain(true);
|
||||
|
||||
const marked = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [loser]);
|
||||
expect(marked.rows[0].superseded_by_id).toBe(survivor);
|
||||
});
|
||||
|
||||
it("works between two statement rows, which reconcile cannot express", async () => {
|
||||
const s1 = await statementLine(25, "2026-08-01", "IMPORTED TWICE");
|
||||
const s2 = await statementLine(25, "2026-08-01", "IMPORTED TWICE");
|
||||
const res = await call(s1, { other_id: s2, mode: "supersede" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [s1]);
|
||||
expect(r.rows[0].superseded_by_id).toBe(s2);
|
||||
});
|
||||
|
||||
it("refuses to hide a statement row behind a statementless one", async () => {
|
||||
// The wrong direction, and silently wrong: a wallet row holds the amount
|
||||
// BEFORE the card's foreign transaction fee, so burying the statement line
|
||||
// under it leaves the ledger holding $42.44 for a $43.71 charge.
|
||||
const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA");
|
||||
const wallet = await insertTransaction(pool, ownerId, {
|
||||
description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16",
|
||||
});
|
||||
const res = await call(stmt, { other_id: wallet, mode: "supersede" });
|
||||
expect(res.status).toBe(400);
|
||||
// and the statement row is untouched
|
||||
const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [stmt]);
|
||||
expect(r.rows[0].superseded_by_id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to bury a row under one that is already superseded", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
const b = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
const c = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
await call(b, { other_id: c, mode: "supersede" }); // b -> c
|
||||
const res = await call(a, { other_id: b, mode: "supersede" }); // a -> b, but b is buried
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals common to both modes", () => {
|
||||
it("refuses a transaction merging into itself", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 1 });
|
||||
const res = await call(a, { other_id: a, mode: "supersede" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses a row that is already reconciled", async () => {
|
||||
const wallet = await insertTransaction(pool, ownerId, { amount: 12, transaction_date: "2026-08-10" });
|
||||
const stmt = await statementLine(12, "2026-08-10", "X");
|
||||
await call(wallet, { other_id: stmt, mode: "reconcile" });
|
||||
const other = await insertTransaction(pool, ownerId, { amount: 12 });
|
||||
const res = await call(wallet, { other_id: other, mode: "supersede" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("refuses a transaction belonging to someone else", async () => {
|
||||
const mine = await insertTransaction(pool, ownerId, { amount: 7 });
|
||||
const theirs = await insertTransaction(pool, otherId, { amount: 7 });
|
||||
const res = await call(mine, { other_id: theirs, mode: "supersede" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { queryRaw } from "../../lib/db";
|
||||
|
||||
/**
|
||||
* The HTTP path had no tests at all — which is how three defects reached the
|
||||
* branch through a suite of 105 green ones. These exercise the route handler
|
||||
* directly (no server needed) so the auth gate and the error taxonomy are
|
||||
* actually covered.
|
||||
*/
|
||||
const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||
|
||||
const TOKEN = "test-ingest-token";
|
||||
|
||||
// The handler is imported dynamically (after the env var is set), so its type
|
||||
// is declared here rather than inferred. `unknown` for the request is
|
||||
// deliberate: these fixtures are the two properties the route actually reads,
|
||||
// not a whole NextRequest, and widening to `any` would stop the compiler
|
||||
// checking the call sites too.
|
||||
let POST: (req: NextRequest) => Promise<Response>;
|
||||
|
||||
// One cast, at the boundary, instead of `any` everywhere downstream: the
|
||||
// fixture is the two properties the route actually reads, and everything past
|
||||
// this line stays type-checked.
|
||||
const req = (body: unknown, token: string | null = TOKEN) =>
|
||||
({
|
||||
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
|
||||
json: async () => body,
|
||||
}) as unknown as NextRequest;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.ORDER_INGEST_TOKEN = TOKEN;
|
||||
({ POST } = await import("../../app/api/orders/ingest/route"));
|
||||
});
|
||||
|
||||
describe("ingest API — auth", () => {
|
||||
it("rejects a missing token", async () => {
|
||||
const res = await POST(req({ html: "x", meta: {} }, null));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a wrong token", async () => {
|
||||
const res = await POST(req({ html: "x", meta: {} }, "nope"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a malformed body", async () => {
|
||||
const res = await POST(req({ html: "only html" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingest API — error taxonomy", () => {
|
||||
const meta = (over = {}) => ({
|
||||
messageId: `api-${Math.random().toString(36).slice(2)}`,
|
||||
subject: "Order Confirmation for Siddharth from Mad Mex",
|
||||
receivedAt: "2026-07-16T03:34:00Z",
|
||||
sender: "DoorDash Order <no-reply@doordash.com>",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("a non-receipt is 200 and silent — it must not alert", async () => {
|
||||
// A newsletter: real traffic, correctly ignored.
|
||||
const res = await POST(req({ html: html("dd-01"), meta: meta({ subject: "Newsletter", sender: "promo@example.com" }) }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).kind).toBe("skipped");
|
||||
});
|
||||
|
||||
it("an adjustment notice is 200 and silent", async () => {
|
||||
const res = await POST(req({
|
||||
html: html("dd-08"),
|
||||
meta: meta({ subject: "Order Confirmation for Siddharth from ALDI" }),
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).kind).toBe("skipped");
|
||||
});
|
||||
|
||||
it("a receipt that cannot be parsed is 422 so it ALERTS", async () => {
|
||||
// A DoorDash receipt with its totals stripped out — i.e. what a provider
|
||||
// template change looks like. Previously this returned 200 and vanished.
|
||||
const broken = html("dd-01")
|
||||
.replace(/Total Charged/g, "Gesamtbetrag")
|
||||
.replace(/Total:/g, "Summe:");
|
||||
const res = await POST(req({ html: broken, meta: meta() }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.kind).toBe("parse_failed");
|
||||
expect(body.reason).toMatch(/total/i);
|
||||
});
|
||||
|
||||
it("a refund is routed to the amendment path, not ingestion", async () => {
|
||||
const res = await POST(req({
|
||||
html: html("ue-05"),
|
||||
meta: meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }),
|
||||
dryRun: true,
|
||||
}));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.kind).toBe("amendment");
|
||||
expect(body.amendment.new_total).toBeCloseTo(45.73, 2);
|
||||
});
|
||||
|
||||
it("a good receipt ingests", async () => {
|
||||
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
|
||||
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
|
||||
const res = await POST(req({ html: html("dd-01"), meta: meta({ messageId: "api-ok-1" }) }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.kind).toBe("order");
|
||||
expect(body.total).toBe(14.64);
|
||||
expect(body.transactionId).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,532 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import {
|
||||
parseOrderHTML,
|
||||
validateOrderTotals,
|
||||
processOrderIngestion,
|
||||
reconcilePendingOrders,
|
||||
parseOrderAmendment,
|
||||
applyOrderAmendment,
|
||||
NotAReceiptError,
|
||||
type MessageMeta,
|
||||
} from "../../lib/order-ingestion";
|
||||
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
|
||||
import { bankLabel, needsCardMatch } from "../../lib/queries";
|
||||
|
||||
/**
|
||||
* These run against REAL captured receipts, not synthetic fixtures. The earlier
|
||||
* suite passed 31/31 against fixtures written to satisfy the parser, while the
|
||||
* parser could not read a single real email. Fixtures live in
|
||||
* __tests__/fixtures/orders/real/ and are unmodified message bodies.
|
||||
*/
|
||||
const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||
|
||||
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
|
||||
messageId: `test-${Math.random().toString(36).slice(2)}`,
|
||||
subject: "Order Confirmation for Siddharth from Mad Mex",
|
||||
receivedAt: "2026-07-16T03:34:00Z",
|
||||
sender: "DoorDash Order <no-reply@doordash.com>",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("Order parsing — real receipts", () => {
|
||||
it("reads DoorDash totals structurally, not by flattening (I9)", () => {
|
||||
const p = parseOrderHTML(html("dd-01"), meta());
|
||||
expect(p.merchant_name).toBe("Mad Mex");
|
||||
expect(p.totals.total_charged).toBe(14.64);
|
||||
expect(p.payment.credits_amount).toBe(14.64);
|
||||
expect(p.line_items).toHaveLength(1);
|
||||
expect(p.line_items[0].description).toBe("Burrito (Mains)");
|
||||
expect(p.line_items[0].options).toContain("Slow Cooked Beef (GF)");
|
||||
});
|
||||
|
||||
it("does NOT gate on DoorDash's fee breakdown, which genuinely does not reconcile", () => {
|
||||
// Real receipt: subtotal 22.10 + service 1.99 - Discounts 24.09 = 0.00,
|
||||
// against a stated total of 14.64. DoorDash prints this; it is not a parse
|
||||
// artefact. Recorded here so nobody "fixes" the parser to force it to sum.
|
||||
const p = parseOrderHTML(html("dd-01"), meta());
|
||||
expect(p.totals.subtotal).toBe(22.10);
|
||||
expect(p.totals.discounts).toBe(24.09);
|
||||
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("derives order_reference from the message, never randomly (I7)", () => {
|
||||
const m = meta({ messageId: "abc123" });
|
||||
const a = parseOrderHTML(html("dd-01"), m);
|
||||
const b = parseOrderHTML(html("dd-01"), m);
|
||||
expect(a.order_reference).toBe(b.order_reference);
|
||||
expect(a.order_reference).toBe("msg:abc123");
|
||||
});
|
||||
|
||||
it("uses Uber's embedded order UUID as the reference", () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.order_reference).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(p.platform).toBe("ubereats");
|
||||
});
|
||||
|
||||
it("takes the order date from the message, not a body string", () => {
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2026-07-16T03:34:00Z" }));
|
||||
expect(p.order_datetime.slice(0, 10)).toBe("2026-07-16");
|
||||
});
|
||||
|
||||
it("detects [Family] from the subject prefix, not a body substring (I11)", () => {
|
||||
const fam = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(fam.is_family).toBe(true);
|
||||
const notFam = parseOrderHTML(html("dd-01"), meta());
|
||||
expect(notFam.is_family).toBe(false);
|
||||
});
|
||||
|
||||
it("reads [Family] orders as LKR, not dollars", () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.currency).toBe("LKR");
|
||||
expect(p.totals.total_charged).toBeCloseTo(3783.20, 2);
|
||||
});
|
||||
|
||||
it("reads Swiss orders as CHF", () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-26"),
|
||||
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.currency).toBe("CHF");
|
||||
expect(p.totals.total_charged).toBeCloseTo(51.23, 2);
|
||||
});
|
||||
|
||||
it("skips a failed payment attempt and takes the successful one", () => {
|
||||
// ue-09: "Visa ••••8841 LKR 4,267.01 ... Failed" then "LKR 3,757.01".
|
||||
const p = parseOrderHTML(
|
||||
html("ue-09"),
|
||||
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.totals.total_charged).toBeCloseTo(3757.01, 2);
|
||||
expect(validateOrderTotals(p, html("ue-09")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an order-adjustment notice rather than booking $0.00", () => {
|
||||
// NotAReceiptError, not OrderParseError: this is expected traffic, so it
|
||||
// must be skipped silently. Only a receipt that fails to parse should
|
||||
// alert — see the ingest API's error taxonomy.
|
||||
expect(() =>
|
||||
parseOrderHTML(html("dd-08"), meta({ subject: "Order Confirmation for Siddharth from ALDI" }))
|
||||
).toThrow(NotAReceiptError);
|
||||
});
|
||||
|
||||
it("rejects a refund notice rather than inserting a duplicate order", () => {
|
||||
expect(() =>
|
||||
parseOrderHTML(html("ue-05"), meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }))
|
||||
).toThrow(/refund/i);
|
||||
});
|
||||
|
||||
it("reads a grocery Final receipt that has no Total Charged row", () => {
|
||||
const p = parseOrderHTML(
|
||||
html("dd-10"),
|
||||
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
|
||||
);
|
||||
expect(p.totals.total_charged).toBeCloseTo(60.93, 2);
|
||||
expect(p.payment.ambiguous).toBe(true); // "8032 and/or credits"
|
||||
});
|
||||
});
|
||||
|
||||
describe("Order ingestion — invariants", () => {
|
||||
beforeEach(async () => {
|
||||
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
|
||||
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
|
||||
// The statement fixtures these tests insert survived into the next run, and
|
||||
// reconcileCardLeg matched a leftover charge at ingest time — so an order
|
||||
// meant to park "awaiting_card_statement" resolved immediately instead.
|
||||
// That is the whole story behind the intermittent failure in "parks an
|
||||
// unresolvable split": not a race, just fixtures that were never cleaned.
|
||||
// Must happen BEFORE ingest, which is why cleaning up at the end of the
|
||||
// test was not enough.
|
||||
await queryRaw(
|
||||
`DELETE FROM statements WHERE filename IN ('test-westpac-2026-03.pdf', 'panel-cba.pdf', 'panel-plain.pdf')`
|
||||
);
|
||||
await queryRaw(
|
||||
`DELETE FROM transactions WHERE description IN ('DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 'UBER *EATS ZURICH')`
|
||||
);
|
||||
});
|
||||
|
||||
it("I6: a credits order creates one transaction at face value", async () => {
|
||||
const p = parseOrderHTML(html("dd-01"), meta());
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
const txn = await queryRow<{ amount: string; payment_method: string; category: string }>(
|
||||
`SELECT amount::text, payment_method, category FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(Number(txn!.amount)).toBe(14.64);
|
||||
expect(txn!.payment_method).toBe("credits");
|
||||
expect(txn!.category).toBe("dining");
|
||||
});
|
||||
|
||||
it("I7: re-ingesting the same receipt creates nothing new", async () => {
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" }));
|
||||
const a = await processOrderIngestion(p);
|
||||
const b = await processOrderIngestion(parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" })));
|
||||
expect(b.skipped).toBe("already_ingested");
|
||||
expect(b.metadataId).toBe(a.metadataId);
|
||||
const n = await queryRow<{ c: string }>(
|
||||
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
|
||||
);
|
||||
expect(Number(n!.c)).toBe(1);
|
||||
});
|
||||
|
||||
it("I1 retired: a credits order before the cutover is recorded, not refused", async () => {
|
||||
// I1 used to store nothing at all for these — no transaction and no
|
||||
// metadata — so the receipt was discarded. Its reason was splits (shared
|
||||
// expenses lived in SplitMyExpenses before 2026-01-09), and that expired
|
||||
// when ACTIVE_OBLIGATION gained its date bound: a pre-cutover split can no
|
||||
// longer assert a debt, so a pre-cutover order cannot move a balance
|
||||
// however it is recorded. All it was still doing was hiding history.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.skipped).toBeUndefined();
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
expect(res.flags).toContain("pre_cutover_credits_order");
|
||||
|
||||
const txn = await queryRow<{ transaction_date: string; payment_method: string; amount: string }>(
|
||||
`SELECT transaction_date::text, payment_method, amount::text
|
||||
FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(txn!.transaction_date).toBe("2025-11-15");
|
||||
expect(txn!.payment_method).toBe("credits");
|
||||
});
|
||||
|
||||
it("a pre-cutover order still creates no split, so no balance moves", async () => {
|
||||
// The whole safety argument for retiring I1. Ingestion writes no splits at
|
||||
// any date; if that ever changes, this fails before a balance does.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z", messageId: "pre-cut-2" }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const n = await queryRow<{ c: string }>(
|
||||
`SELECT count(*)::text c FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(Number(n!.c)).toBe(0);
|
||||
});
|
||||
|
||||
it("I11: a [Family] order records provenance and creates no transaction", async () => {
|
||||
// Requirement was "import them but tag so they're excluded from budgets".
|
||||
// Correct mechanism: the CARD statement line is the transaction and carries
|
||||
// the family tag. Creating a second, credits-flavoured row duplicated it.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||
);
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).toBeNull();
|
||||
expect(res.flags).toContain("family_card_settled_no_transaction");
|
||||
|
||||
const meta_ = await queryRow<{ currency: string }>(
|
||||
`SELECT currency FROM expense_metadata WHERE id = $1`, [res.metadataId]
|
||||
);
|
||||
expect(meta_!.currency).toBe("LKR");
|
||||
});
|
||||
|
||||
it("a foreign-currency order records the original amount and code", async () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-26"),
|
||||
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-04-07T08:53:00Z" })
|
||||
);
|
||||
// Card-settled Swiss order: no credits leg, so no transaction (I5).
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).toBeNull();
|
||||
const meta_ = await queryRow<{ currency: string }>(
|
||||
`SELECT currency FROM expense_metadata WHERE id = $1`,
|
||||
[res.metadataId]
|
||||
);
|
||||
expect(meta_!.currency).toBe("CHF");
|
||||
});
|
||||
|
||||
it("parks an unresolvable split instead of guessing, then resolves it once the statement lands", async () => {
|
||||
const p = parseOrderHTML(
|
||||
html("dd-10"),
|
||||
meta({ subject: "Order Confirmation for Siddharth from Woolworths", receivedAt: "2026-03-02T12:00:00Z" })
|
||||
);
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).toBeNull();
|
||||
expect(res.flags).toContain("awaiting_card_statement");
|
||||
|
||||
// Statement arrives: card 8032 took 40.93 of the 60.93 order.
|
||||
const st = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, filename)
|
||||
VALUES ('Westpac','5163103015778032','test-westpac-2026-03.pdf') RETURNING id`
|
||||
);
|
||||
await queryRaw(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1, '2026-03-02', 'DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 40.93, 'debit')`,
|
||||
[st!.id]
|
||||
);
|
||||
|
||||
const out = await reconcilePendingOrders();
|
||||
// Deliberately not asserting global counts: reconcilePendingOrders() scans
|
||||
// every pending row in the database, so another test's leftovers change the
|
||||
// totals. Assert on THIS order's outcome instead — that is what the test is
|
||||
// actually about, and it does not depend on what else is in the table.
|
||||
expect(out.examined).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const credits = await queryRow<{ amount: string }>(
|
||||
`SELECT t.amount::text FROM transactions t
|
||||
JOIN expense_metadata em ON em.transaction_id = t.id
|
||||
WHERE em.id = $1`,
|
||||
[res.metadataId]
|
||||
);
|
||||
expect(Number(credits!.amount)).toBeCloseTo(20.00, 2); // 60.93 - 40.93
|
||||
});
|
||||
|
||||
it("reconciliation is idempotent — a second pass creates nothing", async () => {
|
||||
// A first pass has already run above; a second must add nothing. Scoped to
|
||||
// 'Order - %' rows so unrelated fixtures cannot move the number.
|
||||
const q = `SELECT count(*)::text c FROM transactions WHERE description LIKE 'Order - %'`;
|
||||
const before = await queryRow<{ c: string }>(q);
|
||||
const out = await reconcilePendingOrders();
|
||||
const after = await queryRow<{ c: string }>(q);
|
||||
expect(out.created).toBe(0);
|
||||
expect(after!.c).toBe(before!.c);
|
||||
});
|
||||
|
||||
it("EXCLUDE_NON_SPEND removes family-tagged rows", async () => {
|
||||
const txn = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, category, transaction_type)
|
||||
VALUES ('2026-03-01','Order - Family Test', 50.00, 'dining', 'debit') RETURNING id`
|
||||
);
|
||||
const tag = await queryRow<{ id: number }>(
|
||||
`INSERT INTO tags (name, color) VALUES ('family','#ef4444')
|
||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`
|
||||
);
|
||||
await queryRaw(`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)`, [txn!.id, tag!.id]);
|
||||
|
||||
const visible = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
|
||||
[txn!.id]
|
||||
);
|
||||
expect(visible).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refund amendments", () => {
|
||||
const ueMeta = (over = {}) => meta({
|
||||
subject: "Your Tuesday evening order with Uber Eats",
|
||||
sender: "uber.com",
|
||||
receivedAt: "2026-07-07T08:17:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("parses a refund notice into an amendment, not an order", () => {
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
expect(a.previous_total).toBeCloseTo(49.94, 2);
|
||||
expect(a.refund_amount).toBeCloseTo(4.21, 2);
|
||||
expect(a.new_total).toBeCloseTo(45.73, 2);
|
||||
expect(a.order_reference).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("reduces the original transaction instead of adding a second row", async () => {
|
||||
// Seed the original order this amendment refers to.
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
const txn = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, amount_aud, category, payment_method, transaction_type)
|
||||
VALUES ('2026-07-07','Order - Coles (Wyndham Vale)', 49.94, 49.94, 'groceries', 'credits', 'debit')
|
||||
RETURNING id`
|
||||
);
|
||||
await queryRaw(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, amount, transaction_date, flags)
|
||||
VALUES ($1,'email',$2, 49.94, '2026-07-07', '[]'::jsonb)`,
|
||||
[txn!.id, a.order_reference]
|
||||
);
|
||||
|
||||
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
const res = await applyOrderAmendment(a);
|
||||
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
|
||||
expect(res.matched).toBe(true);
|
||||
expect(after!.c).toBe(before!.c); // amended in place, no second row
|
||||
const updated = await queryRow<{ amount: string }>(
|
||||
`SELECT amount::text FROM transactions WHERE id = $1`,
|
||||
[txn!.id]
|
||||
);
|
||||
expect(Number(updated!.amount)).toBeCloseTo(45.73, 2);
|
||||
});
|
||||
|
||||
it("invents nothing when the original order was never ingested", async () => {
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
await queryRaw(`DELETE FROM expense_metadata WHERE order_reference = $1`, [a.order_reference]);
|
||||
const res = await applyOrderAmendment(a);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.transactionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("[Family] orders are card-settled, not credits", () => {
|
||||
it("creates provenance but NO transaction — the statement line is the transaction", async () => {
|
||||
// Regression: these were assumed credits-funded because the receipt names
|
||||
// the payer and no instrument. The card statement carries all four (CBA
|
||||
// ...3893, exact LKR matches), so creating a transaction double-counted
|
||||
// spend already recorded.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||
);
|
||||
expect(p.flags).toContain("family_card_settled_no_transaction");
|
||||
expect(p.payment.credits_amount).toBeNull();
|
||||
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).toBeNull();
|
||||
expect(res.metadataId).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owner scoping", () => {
|
||||
it("sets owner_id so the row is visible to the app", async () => {
|
||||
// Regression: analytics scope on COALESCE(t.owner_id, s.owner_id). An
|
||||
// ingested order has no statement, so a NULL owner_id made all 85
|
||||
// backfilled rows invisible in every view while sitting in the table.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `owner-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const row = await queryRow<{ owner_id: number | null }>(
|
||||
`SELECT owner_id FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(row!.owner_id).not.toBeNull();
|
||||
|
||||
const visible = await queryRaw(
|
||||
`SELECT t.id FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1 AND COALESCE(t.owner_id, s.owner_id) = $2`,
|
||||
[res.transactionId, row!.owner_id]
|
||||
);
|
||||
expect(visible).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how an ingested order presents in the app", () => {
|
||||
it("names the restaurant, not the courier", async () => {
|
||||
// Reversed 2026-07-28. The platform was in the headline on request, but it
|
||||
// fragmented the merchant — the same restaurant read differently depending
|
||||
// on who delivered — and it already has a home: the Order details panel
|
||||
// renders expense_metadata.platform next to its heading.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const row = await queryRow<{ description: string }>(
|
||||
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
|
||||
);
|
||||
expect(row!.description).toBe('Order - Mad Mex');
|
||||
});
|
||||
|
||||
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
|
||||
// bank_name is derived — no statement means "Manual", which reads as
|
||||
// "hand-entered, awaiting a card line". A credits order has no card line
|
||||
// coming, ever; 81 of them sat in the queue waiting for one.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `bank-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
|
||||
const row = await queryRow<{ bank_name: string }>(
|
||||
`SELECT ${bankLabel()} as bank_name
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(row!.bank_name).toBe("Gift Card");
|
||||
|
||||
const queued = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
WHERE t.id = $1 AND t.statement_id IS NULL AND ${needsCardMatch("t")}`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records the platform and the message it came from", async () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ messageId: `prov-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
const res = await processOrderIngestion(p, {
|
||||
messageId: `prov-${Date.now()}`,
|
||||
subject: "Your Wednesday order with Uber Eats",
|
||||
sender: "uber.com",
|
||||
});
|
||||
const row = await queryRow<{
|
||||
platform: string; source_email_from: string; route: { label: string }[];
|
||||
}>(
|
||||
`SELECT platform, source_email_from, route FROM expense_metadata WHERE id = $1`,
|
||||
[res.metadataId]
|
||||
);
|
||||
expect(row!.platform).toBe("ubereats");
|
||||
expect(row!.source_email_from).toBe("uber.com");
|
||||
expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("receipt lookup for the transaction detail panel", () => {
|
||||
// Mirrors /api/transactions/[id]/order — the panel resolves a receipt from
|
||||
// either side, and a card-settled order only has the matched_transaction_id
|
||||
// side, which is exactly where the detail would otherwise go missing.
|
||||
const receiptFor = (txnId: number) =>
|
||||
queryRow<{ platform: string; route: { label: string; address: string }[] }>(
|
||||
`SELECT platform, route FROM expense_metadata
|
||||
WHERE transaction_id = $1 OR matched_transaction_id = $1 LIMIT 1`,
|
||||
[txnId]
|
||||
);
|
||||
|
||||
it("finds the receipt for a credits order", async () => {
|
||||
// ue-00 is Uber Cash — credits, so it creates a transaction. ue-09 names a
|
||||
// payer with no instrument and correctly parks awaiting a card statement,
|
||||
// which would leave nothing to look the receipt up by.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ messageId: `panel-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
const res = await processOrderIngestion(p);
|
||||
const r = await receiptFor(res.transactionId!);
|
||||
expect(r!.platform).toBe("ubereats");
|
||||
expect(r!.route.map((x) => x.label)).toEqual(["Pick-up", "Delivery"]);
|
||||
});
|
||||
|
||||
it("finds it from the statement line for a card-settled order", async () => {
|
||||
const st = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, filename)
|
||||
VALUES ('CBA', '5523504401723893', 'panel-cba.pdf') RETURNING id`
|
||||
);
|
||||
const card = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1, '2026-04-07', 'UBER *EATS ZURICH', 51.23, 'debit') RETURNING id`,
|
||||
[st!.id]
|
||||
);
|
||||
const m = await queryRow<{ id: number }>(
|
||||
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
|
||||
VALUES ('email', $1, 'ubereats', '[{"label":"Pick-up","time":null,"address":"Ebikon"}]'::jsonb, $2)
|
||||
RETURNING id`,
|
||||
[`panel-card-${Date.now()}`, card!.id]
|
||||
);
|
||||
expect(m).not.toBeNull();
|
||||
|
||||
const r = await receiptFor(card!.id);
|
||||
expect(r!.platform).toBe("ubereats");
|
||||
expect(r!.route[0].address).toBe("Ebikon");
|
||||
});
|
||||
|
||||
it("returns nothing for an ordinary transaction", async () => {
|
||||
const st = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, filename)
|
||||
VALUES ('CBA', '1111', 'panel-plain.pdf') RETURNING id`
|
||||
);
|
||||
const t = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1, '2026-04-07', 'COLES 1234', 12.00, 'debit') RETURNING id`,
|
||||
[st!.id]
|
||||
);
|
||||
expect(await receiptFor(t!.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
|
||||
/**
|
||||
* Verdicts on orders — the "never order from here again" memory (ING-9).
|
||||
*
|
||||
* These cover the two things that are easy to get silently wrong and invisible
|
||||
* on screen when you do: a second person's verdict overwriting the first, and a
|
||||
* note-only save wiping the per-item opinions. Both are the same shape as the
|
||||
* bug that reset `settled` on split rewrites.
|
||||
*/
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
/** What the review route returns, to the extent these tests read it. */
|
||||
type ItemVerdict = { item: string; verdict: string };
|
||||
type Review = {
|
||||
participant_id: number;
|
||||
rating: string | null;
|
||||
note: string | null;
|
||||
order_again: boolean | null;
|
||||
item_verdicts: ItemVerdict[];
|
||||
};
|
||||
type ReviewBody = {
|
||||
reviews: Review[];
|
||||
splits: { participant_id: number; share_percent: number }[];
|
||||
merchant: {
|
||||
warn: boolean;
|
||||
note: string | null;
|
||||
counts: Record<string, number>;
|
||||
history: { transaction_id: number }[];
|
||||
items: { item: string; loved: number; never: number }[];
|
||||
} | null;
|
||||
};
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
// Declared rather than inferred because the route is imported dynamically,
|
||||
// after vi.doMock. `unknown` for the request keeps the fixture honest — it is
|
||||
// the two properties the handler reads, not a NextRequest.
|
||||
let GET: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
||||
let PUT: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let txnId: number;
|
||||
|
||||
const req = (body?: unknown) =>
|
||||
({ headers: { get: () => null }, json: async () => body }) as unknown as NextRequest;
|
||||
const params = (id: number): Ctx => ({ params: Promise.resolve({ id: String(id) }) });
|
||||
const bodyOf = async (res: Response): Promise<ReviewBody> => (await res.json()) as ReviewBody;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
|
||||
}));
|
||||
vi.doMock("@/lib/queries", () => ({ canAccessTransactions: async () => true }));
|
||||
({ GET, PUT } = await import("../../app/api/transactions/[id]/review/route"));
|
||||
});
|
||||
|
||||
/** An ingested order: a transaction plus the expense_metadata behind it. */
|
||||
async function seedOrder(merchant: string, date = "2026-07-01") {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, merchant_name, owner_id)
|
||||
VALUES ($1, $2, 48.20, 'debit', $3, $4) RETURNING id`,
|
||||
[date, `DoorDash ${merchant}`, merchant, ownerId]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items)
|
||||
VALUES ($1, 'email', $2, $3, '[]'::jsonb)`,
|
||||
[id, `ref-${id}`, merchant]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
|
||||
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
|
||||
ownerId = a.rows[0].id;
|
||||
otherId = b.rows[0].id;
|
||||
txnId = await seedOrder("Thai Palace");
|
||||
});
|
||||
|
||||
describe("order verdicts — one per person", () => {
|
||||
it("keeps both people's verdicts on the same order", async () => {
|
||||
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
|
||||
const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId));
|
||||
const body = await bodyOf(res);
|
||||
|
||||
// The bug this guards: a UNIQUE on transaction_id alone made the second
|
||||
// save overwrite the first, and the disagreement is the useful part.
|
||||
expect(body.reviews).toHaveLength(2);
|
||||
expect(body.reviews.find((r) => r.participant_id === ownerId)?.rating).toBe("loved");
|
||||
expect(body.reviews.find((r) => r.participant_id === otherId)?.rating).toBe("never");
|
||||
});
|
||||
|
||||
it("defaults the verdict to the signed-in user, not the owner", async () => {
|
||||
const res = await PUT(req({ rating: "ok" }), params(txnId));
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].participant_id).toBe(ownerId);
|
||||
});
|
||||
|
||||
it("revising a verdict updates rather than duplicating", async () => {
|
||||
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
|
||||
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews).toHaveLength(1);
|
||||
expect(body.reviews[0].rating).toBe("never");
|
||||
});
|
||||
|
||||
it("rejects a rating outside the scale", async () => {
|
||||
const res = await PUT(req({ rating: "amazing" }), params(txnId));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("derives order_again from the rating", async () => {
|
||||
let body = await bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(false);
|
||||
body = await bodyOf(await PUT(req({ rating: "ok" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(true);
|
||||
// "bad" is also a no — you would not choose it again.
|
||||
body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(false);
|
||||
});
|
||||
|
||||
it("only `never` raises the warning, not `bad`", async () => {
|
||||
// The boundary the five-level scale exists for. A blacklist that fires for
|
||||
// every mediocre meal is one nobody reads, so "bad" records the
|
||||
// disappointment without triggering the alarm.
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
|
||||
|
||||
let body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.counts.bad).toBe(1);
|
||||
expect(body.merchant!.warn).toBe(false);
|
||||
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
|
||||
body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("item verdicts", () => {
|
||||
it("a note-only save does not wipe item opinions", async () => {
|
||||
await PUT(
|
||||
req({
|
||||
rating: "liked",
|
||||
item_verdicts: [{ item: "Pad Thai", verdict: "loved" }],
|
||||
}),
|
||||
params(txnId)
|
||||
);
|
||||
// No item_verdicts key at all — the shape a note-only form sends.
|
||||
const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId));
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
]);
|
||||
expect(body.reviews[0].note).toBe("slow delivery");
|
||||
});
|
||||
|
||||
it("an explicit empty array does clear them", async () => {
|
||||
await PUT(
|
||||
req({ rating: "liked", item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
|
||||
params(txnId)
|
||||
);
|
||||
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].item_verdicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops malformed entries without failing the save", async () => {
|
||||
const res = await PUT(
|
||||
req({
|
||||
rating: "ok",
|
||||
item_verdicts: [
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
{ item: "", verdict: "loved" },
|
||||
{ item: "Curry", verdict: "middling" },
|
||||
],
|
||||
}),
|
||||
params(txnId)
|
||||
);
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].rating).toBe("ok");
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merchant history", () => {
|
||||
it("warns when the merchant was ever marked never, and excludes this order", async () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older));
|
||||
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
expect(body.merchant!.counts.never).toBe(1);
|
||||
expect(body.merchant!.history.map((h) => h.transaction_id)).toEqual([older]);
|
||||
});
|
||||
|
||||
it("treats the same restaurant as one merchant across platforms", async () => {
|
||||
// DoorDash and Uber Eats capitalise differently — "TEG Kebabs & Biryani"
|
||||
// vs "TEG KEBABS & BIRYANI". An exact match split one restaurant's history
|
||||
// in two, so a "never again" recorded through one app never warned in the
|
||||
// other, silently defeating the point of the memory.
|
||||
const shouty = await seedOrder("THAI PALACE", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty));
|
||||
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
expect(body.merchant!.history.map((h) => h.transaction_id)).toContain(shouty);
|
||||
});
|
||||
|
||||
it("does not carry a verdict across different merchants", async () => {
|
||||
const other = await seedOrder("Pizza Place", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
|
||||
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(false);
|
||||
});
|
||||
|
||||
it("pools item opinions across the merchant's orders", async () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(
|
||||
req({ item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
|
||||
params(older)
|
||||
);
|
||||
const older2 = await seedOrder("Thai Palace", "2026-05-01");
|
||||
await PUT(
|
||||
req({ item_verdicts: [{ item: "pad thai", verdict: "loved" }] }),
|
||||
params(older2)
|
||||
);
|
||||
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
// Case-folded: the same dish comes back capitalised differently between
|
||||
// receipts, and two entries for one dish is not a track record.
|
||||
expect(body.merchant!.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
|
||||
});
|
||||
|
||||
it("keeps item opinions from reviews that have no overall rating", async () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(
|
||||
req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }),
|
||||
params(older)
|
||||
);
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("share state", () => {
|
||||
it("reports splits so the panel can show shared vs just me", async () => {
|
||||
let body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.splits).toEqual([]);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txnId, otherId]
|
||||
);
|
||||
body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.splits).toHaveLength(1);
|
||||
expect(body.splits[0].participant_id).toBe(otherId);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { createPool, resetDB } from "./helpers";
|
||||
|
||||
const pool = createPool();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
// This tests the name-substitution logic applied in /api/participants GET.
|
||||
// The rule: the participant matching the current user's ID gets name "Me";
|
||||
// everyone else keeps their real name.
|
||||
function substituteMe(
|
||||
participants: { id: number; name: string }[],
|
||||
currentUserId: number
|
||||
) {
|
||||
return participants.map((p) =>
|
||||
p.id === currentUserId ? { ...p, name: "Me" } : p
|
||||
);
|
||||
}
|
||||
|
||||
describe("participant Me substitution", () => {
|
||||
it("replaces the current user's name with Me", async () => {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
const participants = [
|
||||
{ id: userId, name: "Siddharth" },
|
||||
{ id: userId + 1, name: "Sonu" },
|
||||
];
|
||||
|
||||
const result = substituteMe(participants, userId);
|
||||
expect(result.find((p) => p.id === userId)?.name).toBe("Me");
|
||||
expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu");
|
||||
});
|
||||
|
||||
it("leaves all names unchanged when currentUserId does not match", () => {
|
||||
const participants = [
|
||||
{ id: 1, name: "Siddharth" },
|
||||
{ id: 2, name: "Sonu" },
|
||||
];
|
||||
const result = substituteMe(participants, 999);
|
||||
expect(result).toEqual(participants);
|
||||
});
|
||||
|
||||
it("Sonu sees Me for herself and Siddharth for the primary user", async () => {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id`
|
||||
);
|
||||
const siddharthId = r1.rows[0].id;
|
||||
const sonuId = r2.rows[0].id;
|
||||
|
||||
const rawParticipants = [
|
||||
{ id: siddharthId, name: "Siddharth" },
|
||||
{ id: sonuId, name: "Sonu" },
|
||||
];
|
||||
|
||||
// Siddharth's view
|
||||
const siddharthView = substituteMe(rawParticipants, siddharthId);
|
||||
expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me");
|
||||
expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu");
|
||||
|
||||
// Sonu's view
|
||||
const sonuView = substituteMe(rawParticipants, sonuId);
|
||||
expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth");
|
||||
expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me");
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import { needsCardMatch } from "../../lib/queries";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
NON_RECONCILING,
|
||||
reconcilesAgainstStatement,
|
||||
} from "../../lib/payment-methods";
|
||||
|
||||
/**
|
||||
* These two lists drifted, and the drift was silent for two months.
|
||||
*
|
||||
* Migration 0018 admitted 'credits' to `transactions_payment_method_check` so
|
||||
* the order pipeline could file gift-card-funded orders. Nothing propagated it
|
||||
* to the hand-written allowlist in `PATCH /api/transactions/[id]` or to the
|
||||
* add-transaction dropdown, so the database held 372 rows carrying a value the
|
||||
* UI could not enter and the API refused to set.
|
||||
*
|
||||
* The failure mode is invisible on screen — a gift-card purchase entered by
|
||||
* hand takes the NULL default, and NULL is *reconcilable*, so it joins the
|
||||
* pending queue and is offered card matches that cannot exist. Accepting one is
|
||||
* destructive: reconciled manual rows are filtered out of every query, so the
|
||||
* spend vanishes while an unrelated card row claims to be it.
|
||||
*
|
||||
* So: assert against the database, not against a second copy of the list.
|
||||
*/
|
||||
describe("payment methods mirror the database", () => {
|
||||
it("PAYMENT_METHODS equals the CHECK constraint's vocabulary", async () => {
|
||||
const row = await queryRow<{ def: string }>(
|
||||
`SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
|
||||
WHERE conname = 'transactions_payment_method_check'`
|
||||
);
|
||||
expect(row, "constraint transactions_payment_method_check is missing").not.toBeNull();
|
||||
|
||||
const inConstraint = [...row!.def.matchAll(/'([a-z_]+)'::text/g)].map((m) => m[1]);
|
||||
expect(inConstraint.length).toBeGreaterThan(0); // guard against the regex silently matching nothing
|
||||
expect([...inConstraint].sort()).toEqual([...PAYMENT_METHODS].sort());
|
||||
});
|
||||
|
||||
it("reconcilesAgainstStatement() agrees with needsCardMatch() for every method", async () => {
|
||||
// Evaluate the real SQL predicate rather than re-implementing it: this is
|
||||
// the TypeScript mirror, so the SQL is the thing being mirrored.
|
||||
const rows = await queryRaw<{ pm: string | null; sql_reconciles: boolean }>(
|
||||
`SELECT v.payment_method AS pm, ${needsCardMatch("v")} AS sql_reconciles
|
||||
FROM (SELECT unnest($1::text[]) AS payment_method) v`,
|
||||
[[...PAYMENT_METHODS, null]]
|
||||
);
|
||||
expect(rows).toHaveLength(PAYMENT_METHODS.length + 1);
|
||||
|
||||
for (const { pm, sql_reconciles } of rows) {
|
||||
expect(reconcilesAgainstStatement(pm), `payment_method = ${pm ?? "NULL"}`)
|
||||
.toBe(sql_reconciles);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds cash and credits out of the queue, and nothing else", () => {
|
||||
expect([...NON_RECONCILING].sort()).toEqual(["cash", "credits"]);
|
||||
// NULL means *unknown*, never "no card leg is coming" — it must stay a
|
||||
// candidate or every pre-existing row would drop out of reconciliation.
|
||||
expect(reconcilesAgainstStatement(null)).toBe(true);
|
||||
expect(reconcilesAgainstStatement(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user