Add full project structure: backend, frontend, Docker, and CI workflows
- Organize backend into src/ (routes/, services/, db/) per package.json entrypoint - Add migrations/import-mdb.js for one-time .mdb → SQLite migration - Add public/ frontend: check ledger table, slide-in new/edit panel, PDF generation - Add docker/Dockerfile and docker-compose.yml for self-hosted deployment - Add .github/workflows: Docker Hub build+push on main/tags, TODO→Issues scanner - Add GnuMICR font files (GPL-2.0) for MICR E-13B line rendering
This commit is contained in:
@@ -0,0 +1 @@
|
||||
MICR_FONT_PATH=/app/fonts/micrenc.ttf
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Build and push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/ezcheck
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,28 @@
|
||||
name: TODO to Issues
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
todo:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on merge commits from the action itself to avoid loops
|
||||
if: "!contains(github.event.head_commit.message, '[bot]')"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan TODOs and create issues
|
||||
uses: alstr/todo-to-issue-action@v5
|
||||
with:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CLOSE_ISSUES: true
|
||||
AUTO_ASSIGN: true
|
||||
IDENTIFIERS: |
|
||||
[
|
||||
{"name": "TODO", "labels": ["enhancement"]},
|
||||
{"name": "FIXME", "labels": ["bug"]},
|
||||
{"name": "HACK", "labels": ["technical-debt"]}
|
||||
]
|
||||
+11
@@ -137,3 +137,14 @@ dist
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
fonts/*.ttf
|
||||
fonts/*.otf
|
||||
|
||||
#AI
|
||||
CLAUDE.md
|
||||
/.claude
|
||||
|
||||
@@ -19,3 +19,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
---
|
||||
|
||||
fonts/GnuMICR.otf is licensed separately under the GNU General Public
|
||||
License v2.0. See fonts/LICENSE for details.
|
||||
|
||||
@@ -1 +1,86 @@
|
||||
# check-printing
|
||||
# ezcheck
|
||||
|
||||
Self-hosted web app for printing checks on blank check stock. Replaces ezCheckPrinting (Halfpricesoft).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime:** Node.js 20
|
||||
- **Framework:** Express
|
||||
- **Database:** SQLite via `better-sqlite3`
|
||||
- **PDF generation:** PDFKit with embedded MICR E-13B font
|
||||
- **Frontend:** Vanilla JS, no framework
|
||||
- **Container:** Docker Compose
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ezcheck/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── checks.js # CRUD for check records
|
||||
│ │ ├── accounts.js # Account config (Phase 2)
|
||||
│ │ └── pdf.js # PDF generation endpoint
|
||||
│ ├── services/
|
||||
│ │ └── pdfService.js # PDFKit rendering logic
|
||||
│ ├── db/
|
||||
│ │ ├── schema.sql # SQLite schema
|
||||
│ │ └── database.js # DB connection + helpers
|
||||
│ └── app.js # Express app
|
||||
├── migrations/
|
||||
│ └── import-mdb.js # One-time .mdb import script
|
||||
├── public/
|
||||
│ ├── css/style.css
|
||||
│ ├── js/app.js
|
||||
│ └── index.html
|
||||
├── fonts/ # MICR E-13B TTF goes here
|
||||
├── docker/
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development (local)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
node migrations/import-mdb.js --file /path/to/YourAccount.mdb
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
The import script reads a single `.mdb` file and populates the SQLite database.
|
||||
It requires `mdbtools` to be installed on the host or available in the container.
|
||||
|
||||
```bash
|
||||
node migrations/import-mdb.js --file "Montana Dinosaur Center.mdb"
|
||||
```
|
||||
|
||||
## Printing
|
||||
|
||||
- Select 1–3 checks from the ledger
|
||||
- Click "Print PDF"
|
||||
- App generates a 3-up 8.5"×11" PDF (three 3.667" check slots)
|
||||
- PDF opens in browser, user sends to printer
|
||||
- Checks are marked as printed in the ledger
|
||||
|
||||
## Check Layout Coordinate Space
|
||||
|
||||
Coordinates are in inches. Origin is top-left of each check slot.
|
||||
Check slot dimensions: 8.5" wide × 3.667" tall (three per letter page).
|
||||
MICR line is hardcoded at Y = 3.4" (0.267" from bottom of slot).
|
||||
|
||||
## MICR Font
|
||||
|
||||
Place `micrenc.ttf` or `GnuMICR.ttf` in the `fonts/` directory.
|
||||
Update `MICR_FONT_PATH` in `.env` if using a different filename.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# TODO
|
||||
|
||||
## MVP
|
||||
|
||||
- [ ] Build `public/index.html` -- app shell, header with company name and current check number
|
||||
- [ ] Build `public/css/style.css` -- functional, dense layout; ledger table is primary view
|
||||
- [ ] Build `public/js/app.js` -- all frontend logic via `fetch()`
|
||||
- [ ] Check ledger table -- columns: check #, date, payee, amount, memo, printed status
|
||||
- [ ] Ledger: filter by printed / unprinted
|
||||
- [ ] Ledger: sort by check number and date
|
||||
- [ ] New check form -- fields: payee, amount, date, memo, note1, note2; address fields collapsed by default
|
||||
- [ ] New check form -- slide-in panel or modal, not a separate page
|
||||
- [ ] Edit mode for unprinted checks (inline or same panel as new check form)
|
||||
- [ ] Checkbox selection of 1--3 checks for print; enforce the 3-check maximum in the UI
|
||||
- [ ] "Generate PDF" button -- POST to `/api/pdf`, open resulting PDF in a new browser tab
|
||||
- [ ] Reprint flow -- allow re-generating PDF for already-printed checks without re-marking (`?mark_printed=false`)
|
||||
- [ ] Delete confirmation for unprinted checks
|
||||
- [ ] Basic error display for API failures (failed PDF generation, validation errors)
|
||||
- [ ] Amount input validation -- numeric, two decimal places, greater than zero
|
||||
- [ ] Date input defaults to today
|
||||
- [ ] Check number display -- show next check number on new check form (read from account)
|
||||
- [ ] Run migration against `Montana Dinosaur Center.mdb` and verify all check records import correctly
|
||||
- [ ] Verify PDF output: spot-check field positions against a printed check from the original software
|
||||
- [ ] Verify MICR line renders using GnuMICR.otf and lands at correct Y position
|
||||
- [ ] Docker Compose smoke test -- confirm app starts, DB initializes, and PDF endpoint responds
|
||||
|
||||
---
|
||||
|
||||
## Post-MVP / Future Features
|
||||
|
||||
These exist in the original ezCheckPrinting software but are intentionally out of scope for MVP.
|
||||
|
||||
### Multi-account support
|
||||
- [ ] Account switcher -- the original software has ~14 accounts across TMDC, Lions, District 37, etc.
|
||||
- [ ] `account_id` foreign key on `checks` and `layout_fields` tables
|
||||
- [ ] Account management UI -- create, edit, delete accounts
|
||||
- [ ] Per-account logo and signature image upload
|
||||
- [ ] Per-account bank configuration (routing, account number, transit code, company info)
|
||||
|
||||
### Check layout editor
|
||||
- [ ] Visual layout editor -- drag or nudge field positions (X/Y in inches)
|
||||
- [ ] Per-field font, size, and visibility toggles
|
||||
- [ ] Printer offset calibration UI (offset left/right/up/down) for aligning to check stock
|
||||
- [ ] Preview panel that reflects layout changes before saving
|
||||
|
||||
### Payee management
|
||||
- [ ] Payee address book -- store and recall payee name + address lines
|
||||
- [ ] Autocomplete payee field from address book on new check form
|
||||
|
||||
### Check stub
|
||||
- [ ] 1-up with stub layout (alternative to 3-up) -- stub fields are already in `layout_fields`
|
||||
- [ ] Stub field rendering in PDF service (fields prefixed `Stub` in `layout_fields`)
|
||||
|
||||
### Reporting and ledger features
|
||||
- [ ] Date range filter on ledger
|
||||
- [ ] Search/filter by payee name
|
||||
- [ ] Total amount display for filtered ledger view
|
||||
- [ ] CSV export of check ledger
|
||||
|
||||
### Import / migration
|
||||
- [ ] Multi-account `.mdb` import -- run migration script per account, associate with account record
|
||||
- [ ] Import logo from `.mdb` `Settings` table for accounts that have one (already implemented in script, needs UI trigger)
|
||||
|
||||
### Authentication
|
||||
- [ ] Basic auth or simple password gate for any deployment that leaves the local network
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
ezcheck:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: ezcheck
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# Persistent data: SQLite DB lives here
|
||||
- ezcheck-data:/app/data
|
||||
- ./fonts:/app/fonts:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_PATH=/app/data/ezcheck.db
|
||||
# Full path to MICR font inside container
|
||||
- MICR_FONT_PATH=${MICR_FONT_PATH}
|
||||
|
||||
volumes:
|
||||
ezcheck-data:
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# mdbtools for migration script (only needed on first run, stays in image for convenience)
|
||||
RUN apk add --no-cache mdbtools
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# Data volume: SQLite database and any runtime uploads
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/app.js"]
|
||||
@@ -0,0 +1,3 @@
|
||||
GnuMICR was created by:
|
||||
|
||||
Eric Sandeen <sandeen-gnumicr@sandeen.net>
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
GnuMICR Changelog
|
||||
|
||||
December 10, 2003
|
||||
0.30 Re-generated the TTF font using pfaedit (so I can recreate)
|
||||
Generated otf font file using pfaedit
|
||||
Added StdVW to StemSnapV array (Thanks t1lint!)
|
||||
Fixed typo IsFixedPitch -> isFixedPitch
|
||||
Rebuilt pfa and pfb with t1utils v. 1.29
|
||||
Removed blather about distribution w/ a commercial
|
||||
application, that's not consistent w/ the GPL.
|
||||
Updated contact information.
|
||||
|
||||
August 12, 2000
|
||||
0.22 Included a converted TTF font (also untested)
|
||||
|
||||
August 2, 2000
|
||||
0.21 Added Unique ID number for the font
|
||||
Added some copyright information
|
||||
|
||||
July 2, 2000
|
||||
0.2 Recompiled fonts with 64 char lines (oops)
|
||||
|
||||
July 1, 2000
|
||||
0.1 First public release
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
675 Mass Ave, Cambridge, MA 02139, USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Library General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Appendix: How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) 19yy <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) 19yy name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Library General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,36 @@
|
||||
StartFontMetrics 2.0
|
||||
Comment Generated by pfaedit
|
||||
Comment Creation Date: Thu Dec 11 12:40:09 2003
|
||||
FontName GnuMICR
|
||||
FullName GnuMICR
|
||||
FamilyName GnuMICR
|
||||
Weight Normal
|
||||
Notice (Copyright (c) 2000-2003, Eric Sandeen <sandeen-gnumicr@sandeen.net>. Released under the terms of the Gnu Public License, www.gnu.org)
|
||||
ItalicAngle 0
|
||||
IsFixedPitch false
|
||||
UnderlinePosition -100
|
||||
UnderlineThickness 50
|
||||
Version 000.300
|
||||
EncodingScheme FontSpecific
|
||||
FontBBox 10 0 720 702
|
||||
StartCharMetrics 18
|
||||
C 0 ; WX 500 ; N .notdef ; B 0 0 0 0 ;
|
||||
C 32 ; WX 751 ; N space ; B 0 0 0 0 ;
|
||||
C 48 ; WX 751 ; N zero ; B 103 0 649 702 ;
|
||||
C 49 ; WX 751 ; N one ; B 337 0 649 702 ;
|
||||
C 50 ; WX 751 ; N two ; B 337 0 649 702 ;
|
||||
C 51 ; WX 751 ; N three ; B 259 0 649 702 ;
|
||||
C 52 ; WX 751 ; N four ; B 181 0 649 702 ;
|
||||
C 53 ; WX 751 ; N five ; B 259 0 649 702 ;
|
||||
C 54 ; WX 751 ; N six ; B 181 0 649 702 ;
|
||||
C 55 ; WX 751 ; N seven ; B 259 0 649 702 ;
|
||||
C 56 ; WX 751 ; N eight ; B 103 0 649 702 ;
|
||||
C 57 ; WX 751 ; N nine ; B 181 0 649 702 ;
|
||||
C 65 ; WX 751 ; N A ; B 103 0 649 702 ;
|
||||
C 66 ; WX 751 ; N B ; B 103 0 649 702 ;
|
||||
C 67 ; WX 751 ; N C ; B 103 117 649 663 ;
|
||||
C 68 ; WX 751 ; N D ; B 103 195 649 507 ;
|
||||
C 169 ; WX 751 ; N copyright ; B 10 10 720 120 ;
|
||||
C -1 ; WX 500 ; N CR ; B 0 0 0 0 ;
|
||||
EndCharMetrics
|
||||
EndFontMetrics
|
||||
@@ -0,0 +1,215 @@
|
||||
%!FontType1-1.1: GnuMICR 000.300
|
||||
%%CreationDate: Wed Aug 02 19:41:00 2000
|
||||
%%VMusage: 120000 150000
|
||||
%(The above line is most likely not correct)
|
||||
%
|
||||
%---------------
|
||||
%
|
||||
% GnuMICR - a free implementation of the MICR font
|
||||
%
|
||||
% Copyright (C) 2000-2003 Eric Sandeen (sandeen-gnumicr@sandeen.net)
|
||||
%
|
||||
% This program is free software; you can redistribute it and/or modify
|
||||
% it under the terms of the GNU General Public License as published by
|
||||
% the Free Software Foundation; either version 2 of the License, or
|
||||
% (at your option) any later version.
|
||||
%
|
||||
% This program is distributed in the hope that it will be useful,
|
||||
% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
% GNU General Public License for more details.
|
||||
%
|
||||
% You should have received a copy of the GNU General Public License
|
||||
% along with this program; if not, write to the Free Software
|
||||
% Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||
%
|
||||
%---------------
|
||||
%
|
||||
% This font contains only the digits 0-9, and 4 symbols
|
||||
% To get the symbols, use the characters A B C D
|
||||
%
|
||||
% To convert back and forth between an editable raw font file
|
||||
% and an encoded, encrypted, useable Type 1 font file, you will need
|
||||
% the t1utils package from http://www.lcdf.org/~eddietwo/type/
|
||||
%
|
||||
%---------------
|
||||
%
|
||||
% TODO:
|
||||
% Get this thing inspected by a bank...!
|
||||
% Implement stem hint replacement ("3", "8", Symbols)
|
||||
% Angles/arcs on "7" may not be quite right
|
||||
%
|
||||
%---------------
|
||||
%
|
||||
11 dict begin
|
||||
/FontInfo 14 dict dup begin
|
||||
/version (000.300) readonly def
|
||||
/Copyright (Copyright 2000-2003, Eric Sandeen) readonly def
|
||||
/Notice (Copyright (c) 2000-2003, Eric Sandeen <sandeen-gnumicr@sandeen.net>. Released under the terms of the Gnu Public License, www.gnu.org) readonly def
|
||||
/FullName (GnuMICR) readonly def
|
||||
/FamilyName (GnuMICR) readonly def
|
||||
/Weight (Normal) readonly def
|
||||
/ItalicAngle 0 def
|
||||
/isFixedPitch false def
|
||||
/UnderlinePosition -100 def
|
||||
/UnderlineThickness 50 def
|
||||
end readonly def
|
||||
|
||||
/FontName /GnuMICR def
|
||||
/PaintType 0 def
|
||||
/FontType 1 def
|
||||
/FontMatrix [ 0.00100 0 0 0.00100 0 0 ] readonly def
|
||||
/Encoding 256 array
|
||||
0 1 255 {1 index exch /.notdef put } for
|
||||
dup 32 /space put
|
||||
dup 48 /zero put
|
||||
dup 49 /one put
|
||||
dup 50 /two put
|
||||
dup 51 /three put
|
||||
dup 52 /four put
|
||||
dup 53 /five put
|
||||
dup 54 /six put
|
||||
dup 55 /seven put
|
||||
dup 56 /eight put
|
||||
dup 57 /nine put
|
||||
dup 65 /A put
|
||||
dup 66 /B put
|
||||
dup 67 /C put
|
||||
dup 68 /D put
|
||||
dup 169 /copyright put
|
||||
readonly def
|
||||
|
||||
/FontBBox { 103 0 649 702 } readonly def
|
||||
currentdict end
|
||||
currentfile eexec
|
||||
d9d66f633b846a989b9974b0179fc6cc445bcf7c3c3333173232e3fdbff43949
|
||||
1db866c39088c203dc22fdc758584860ec7bb67fda28cc6208249060e18fab32
|
||||
204779b5c03c0493bbbbc95cf02692cc4deaa8d2ea90b5c2e64374e92bcb8501
|
||||
429b8fae4a76c0c6b76d6ff7cf9a7d5edfbca0e959541c59bd05b7de43d25d53
|
||||
fc3dda6ef0c2743978a6d03e19cced4a11f2ea4bcc3110be8b8d9e2772361969
|
||||
c19258efafdc276cb1ade9208a941a36d18f9fb1c33def76aa3140a8a4c99adb
|
||||
b3214e61cb091bb87421cef35ff5745ef8dab2327f4505291949c5c81a2c61a1
|
||||
c68bc0c2724684a65c13e201889ed4829f0502939b52213248c263db1fe10129
|
||||
8a2904757b2fb30240088b194cd883e258e163d6ecf1d233c50b7245be021177
|
||||
eac51a7a1a807977163b3a28a3c9f79d5116de6525552753c1d3aa7bb4ec7b18
|
||||
837ef3428d84afc12dd6cb1f27e859ffb74c151b97b4a6ff35710cdce68fcbaa
|
||||
ab650eeeffc0c2bc940fabcd1da75ad2bd6cb6e0567455fb1de69f17d1474b1f
|
||||
be7eec173206d8b4a85a24d9cfb113b2ebf1e8790e6e02c643418d0a8d19a5bf
|
||||
48528e6a4d92065c9b68886a9093256bedf9a90ecbedcbaa21f5194ef7e7cdb2
|
||||
68b296d436bcb83abeb6d0e64b498d3fa1d4f67418d72f1ffcbefcd0bc198738
|
||||
46537a5f6147e88a422a858ea0d8035cb03be90cc441ebe8f38fad0ba587a8b8
|
||||
6106ff8c87f2add0df9bb2384795042dfce4ba9b2c49269249640a8792a966b7
|
||||
09e7f16df067017e7253e273d2b2c495d12848b56aeb76cad0a1d217fcf3ec3f
|
||||
e474793c37ea3acbf375302c907e888e48c77767306c2d64f1b66d625e5f7270
|
||||
40fbfb2438ff6eba6c72759808e9556910b61627058848d1736ae22f55ff2d0f
|
||||
caf500e55ede2fa5add20b2622909a1b906b68cf458ad50a33d453328e6b6d46
|
||||
0942916d050df3fda98862355b6d0f9e931ca94db56d0aef32dcb68fc2fcb1ca
|
||||
3b05ec92191ef9d190dc14b70ece10a09e4b5e3745e041462f97907120e0c24d
|
||||
efa34a171a43ab03a31edcca00299cf085f74356901e6e858643e060e0531f33
|
||||
227904d8d5e5e7f0ab1f2b5c97a5e45647321138d4856a1fec830e7075bdcebd
|
||||
b0d1d481f6c79500619b1b3a725237aaf4879364007d48c64c19a374d2e21792
|
||||
bc0cbef879e0a348bf86a62d4d7d0526cc64ab1928a5642cd8bdbc55bf85dad8
|
||||
1a2ba2f7e4537706878f74d6b2be523f8a9de00c5d01184405487f1c873f102e
|
||||
a0a9bef2f2fa9718d8cade54f5cc17aadaa78280472a1f749c20583d9c0a1106
|
||||
ad73decc7efcb4de4315683e204d7a8c0512e41e7e501b4884e4ee90d08652f3
|
||||
4c15e337e74dd4823e4aa117251c3cd02e27827f07297bf2b08a8087ccce4f3f
|
||||
cd86d361e3dd8d11ba1c4b58d5838923342d8b23ae88b25c4410e4fa85eba1ac
|
||||
1c676b35bc3a344525248fe25e4619e4476cdc566729217d325f88ef1b752851
|
||||
508f90bab8aca5d9382414a114a0d903b16ce7821442e01312d58b5e2e26c0df
|
||||
cf0d1454678e3b9a9b157ddc49d3a7f7235666ebef29cba7360a113f540bd8ae
|
||||
fac6dfc4b9d16c8e754376d8eb73f86b3469b94717251c3c2eb411f1df2acac9
|
||||
96ce622d50e289ae896b9e01227284244683504fed3bc7d32c79a1053e6883b4
|
||||
003fe9244cab401ba5566469541e7f6d10606cd188deebb7e8858900aea86ea5
|
||||
0d412a03bd075cd2d3ac38a202db7634197e4a6940ed83d32733ed4228f4a208
|
||||
316ce4daa9ebe5f14ff089ac978d0a39303aa0eaca67ac5132d592391c6d8593
|
||||
cbc678fad548ffab092d702ac1f31c3c34e7dc9c66655403fdee707c7c31b1bb
|
||||
17909887c564967f49604f9eb776cb9720fead110e878e0f003261a54dc6fa01
|
||||
eec1a40ff45fefa7d85001e3b322c7447293cf20cc5d8461be725b4d75efde5e
|
||||
98a9ae0008490739cd664a7defd93b444dd65ff1c7e8f8f8a3ba8b1cea0e2fbb
|
||||
315a4eccbf09993c402534d2721ea64afdebdad1ece3ac1939f96cd777b17de8
|
||||
b9d527d32ce26f4e1f861ed5916da9523f058fc3cdaa367756879d5353dae748
|
||||
4bbbda7de04edd2ace4922af206631719471324e3c6b8282f01d53861389c2d6
|
||||
b54306763edefb2e049ace70526afba2d782e354ed9a2945f4a0470ed4c7b418
|
||||
3566e0bb1d94d945cb8d94e438026ff260ba5ce66d4d51fd4887214fbe26e928
|
||||
ff262e98518b92b40caa5bcfe73ea8cbf86c7ec1c89866c537242307190fed28
|
||||
23f66362aff372e7da620fdf0daa086856278b06b417964c9bb13560be9a6644
|
||||
360b70f0def155ed64bdb26f1733d6fe91a04aaf2e45534c5865768c9cb901ef
|
||||
e56144ecb22046dd87d12e7a8826abfc8728249617b5bfe2850b7063136a5518
|
||||
84d244913e06f72d094b3453542ec6d6375bd3d99e5f158d5f6889229bc9c609
|
||||
fe1d0b2a511be50f7b91af54e8200f1606ca468e30b51c7d37eac9d8a640589b
|
||||
14daa550358e5a6f462b0b6fa7ffa3cf79e686a9cb0bfe66d728755ca37d4fa2
|
||||
a4bc2f74b03fb31ab682bd95565b1011c6cc1437b995c1bc3c273c65904f4c9f
|
||||
de274a8bd8f5bf97a1addce6194b9240a32d372451fe699b0afab349139e2168
|
||||
3928d1b65ab575347f08cd88f52a6b9cb0c5b0448fc83ac3b25c1e867888ea0f
|
||||
bf9800cb5e6509fe5eee739cdc1359fd09bed912ad1997d1f2a89d124bb18e7c
|
||||
a0f1078cb35f41ae0347cd900c30e871a84cce6e2ddeb210e33c5b9e6db78fd8
|
||||
fa8e61a4294c90acb1e3a4d69168bc67cde8e96272d2bbd7c890c7ba211176ad
|
||||
786e99843c10b7618955a244f5f7a8795735989327124dfc25ec3a005fd84b05
|
||||
9e4ee615545555d72f6e6fc371712aa16bc6d08d1df5953d962103f3b76f0501
|
||||
e18699411aaf89f876a019516ae3ef8bdfbe31bfb1594ec0ee8ee3b7171493c8
|
||||
347e2a9b250816a45800514b7e63aa0c1094f62678732da76460a55f02528abf
|
||||
0f9bd557abab16eb2927b5c68be71f466c58af23c67a7a15c82b277cc8163d74
|
||||
9517aa9ef177d3bea03aafd08d5585629cc17d97e77510a59b29316d1b67ff85
|
||||
0773244e688edc30cbf1528ade8840c5f492247b89841b8e03d047eb80d9ce49
|
||||
94d0483348697bd1f24173de4806df1208facd8f8797e1761a548b82bdfb3467
|
||||
f0b6a840812221026531d561866f693c1b28f63ca2fe4f458678e084869a381d
|
||||
b37b6cd52234c28f1a69b4b611db245ff2ec1d6f82bc04d4df67a6bb7cfef7c4
|
||||
fc751ba88d114592fca0bf7ec26bfaf4909047c3621510588266d57efb10858d
|
||||
9bb05064d331e4b1f6ea87feb979f6376e848eb204a7bdfc3581a6f97734c680
|
||||
c09722bf3ed67b24ec7f40e3f0afd702dc639e1541489b864bd53c4d1fc6a5ba
|
||||
0cc887eb451b6fabbb028ffbc05eab5c0c5b9c111895943c6ce54eb0d94fa1d1
|
||||
ce3bab45cac7a6270d4668b6a75211515ad0401a16512b1aa22f775c2726ed25
|
||||
22247b78e865f7835d9641f7c25992a0f596b12f85400a753fbfd36c50e89036
|
||||
d5fd47bf945009a0ed48d44e7201f03511865adabc24e3f65a994fd9ef121f02
|
||||
ad010f75bb840f82d91467e5dfabd033febbb96d3a0e2e667df8c3410fd538e6
|
||||
aac9aefbcffc22eb77b86e3e609d282666d7e8e26a678cc29d256ae3b78bb993
|
||||
0580c39eba3c5f25fe02ea5dba6f872fecdd1264b32ef27d95d7d074de99233d
|
||||
799992a9656cd581407b82f1025ba5fcf3ac88dc319c2c1af7e8a4479d9d1a64
|
||||
7ecac0ad08980115d1082a35d2ee9154b6e7e1ab816a1447f23bb196fbd5e022
|
||||
afb9b81b9abcd1ccfbfb95321ecc3cc3e351ff6ee4afc8da56e5ded1a4554dbf
|
||||
0c2a22601441f90090cd1a5ca9f3496ea04d22b79e949e550c104deb409a0e20
|
||||
250c0455b0d4a0526d0e39c303f3a3a6efaf686ec5206bf3e595ff70d5a4346f
|
||||
2a3984ce45121cf2d1928f96dca05cad7760bdf639a1cf528940240fa1748c34
|
||||
29233744bcf4f2f29c6bb830d978bec8bf6968ac6785e19df57c263ed490fc57
|
||||
392f29e6ad61702f1912b28d4da44c60ab0c1464b9c80ed4298475ea1173f48f
|
||||
441cf51a7feddc28d9aa4e5369aae7f127825f69da2cb13c5a15db82863ff379
|
||||
ca0efb3ab2ee7b1c54b6254d6de4bd405af22dd455ed2369c4472ad539754ce4
|
||||
eab1e3524d7ffc75333c5e18bde6ef56d474552563ced15cdef488f661471b8b
|
||||
a9bbc4c1815dccc94b40cb7e00b89b679d53188b6fbc3998b82380dbb00e37d8
|
||||
ba3e692d276eae6f4c8f3fe5c194c0d5f780496ed756b7dd6f588f786971612a
|
||||
ff579c5aaf17b17f5fa640f3f5afbdb397b89a33c6a614372b74812a003a67f6
|
||||
bd6829c688fba52b924a4d4edacb02a113a57a5dd4c05badd7ac0a2b9592cec0
|
||||
e04ae2242d04ccfb445897de02e5b8b69ad754006a5a50bc21436e198faed411
|
||||
eb8d95b56162c6c239eb7adc1382f05b4ee36064d9741207f3747a4195219d61
|
||||
ca83b9717c7755dfa846f80b8b7f2356e5a4a47c4a87e83c5e9f28387c0d3a03
|
||||
7224ee2300e94bcfbb8832b54a76c043a591e611418e97f8ed9d6b94214f6ed4
|
||||
d918a9ee26fb160495b22906d40ebad0a4e270ef5d1afe3a07deca7ea084233d
|
||||
8d89a346e7864646dd09205a07c9ba4d4e952152d3b99c58568691f67d9dcd8c
|
||||
6a2f97e00763912f830e83029606c40c54da583bbc68eb58ea3144b1028f4763
|
||||
b5096808920788fcb80d2e868a7fb408479a4cca3a7fa1178ac907311137f3b1
|
||||
676be8a4cafc56815b05d6083d569930961ab84e3bd3541bca06df99ee407759
|
||||
d9c69f0e98bd56b8cfcce7bbae32bab38619c44fdd584af44a5448c5a0b25e59
|
||||
30ab3782dd0aef6ce50c362360a172348eb82c0df84d93f7297705513e39e760
|
||||
a68db79b1b9abcc1245e8d5cefb69494773a247127f0a5ec7d01edb35e378eab
|
||||
7844363315dc364785f78919d765af8611b76fb4344ee3e9ad16014eeabc91c7
|
||||
2f6b488530ddd4f5fefa94152f1ed0fd784f14f60bbf8d175a11bbc31e2b2b40
|
||||
463df7387cc8305b172283b85e0025e208c9380898628c07726dfe7ace4f3c54
|
||||
8f14c9443bfd25d7a61897fd9e984099726df51c0092750771715787a89dcde1
|
||||
1ce64902a677fb4a1c0a54dbf851f88eb0a46cef180c118b228a3bbe3cec1c4a
|
||||
8d2042f182f813a134753ebaa7b9950c121935b709f6e54015b21dac0eefadba
|
||||
f284e0962d504d71489c5f7f54619c1092420ff272cd0dcb988403dc166c3b68
|
||||
8febb945ce3ebfa53208a875206cb86770cf82160fe004623ca47ebe928cfdcc
|
||||
11ee9a57ffc4df10051eb7f5452f96515314be80fc1555689a32ba09b424d751
|
||||
9b2763f88e49718e5e9e3e8f022df7864edb9e1c356c141f88af9fef133fd2dc
|
||||
3e51fa7973cf674a6671fc705210278564908f0f343ae56738f92277a636c669
|
||||
8a3d69014dbb1ae80f7b181712d583f744f7b975794e38250d27d6cffabee62b
|
||||
5eaf1b6fa90b32fd15023d63b4fd903b569542838b6df15ceae6bad0f2c2cc99
|
||||
7592f323cab1ea7030ecc20bca409dd5a9db
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
||||
Binary file not shown.
Binary file not shown.
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
If you're using this font under Microsoft Windows, I can't help you.
|
||||
|
||||
If you're using it under a Linux-y operating system, there are documents
|
||||
available which do a better job than I ever would at explaining how to
|
||||
install them.
|
||||
|
||||
In general, take a look at the Font-HOWTO, available from
|
||||
http://www.linuxdoc.org/HOWTO/Font-HOWTO.html particularly
|
||||
|
||||
"Making Fonts Available To X"
|
||||
http://www.linuxdoc.org/HOWTO/Font-HOWTO-4.html
|
||||
|
||||
and
|
||||
|
||||
"Making Fonts Available to GhostScript"
|
||||
http://www.linuxdoc.org/HOWTO/Font-HOWTO-5.html
|
||||
|
||||
I have included a test PostScript(tm) file called "test.ps" that you can
|
||||
fire up in GhostScript to see if you have that set up correctly.
|
||||
|
||||
I have also included a file, "symbols.png" to show you which characters
|
||||
you need to type to get the various MICR symbols.
|
||||
|
||||
Note that to function in the MICR readers, this font must be sized
|
||||
exactly right. GnuMICR is designed to be used at 12 point size.
|
||||
|
||||
Good luck!
|
||||
@@ -0,0 +1,3 @@
|
||||
GnuMICR.otf is licensed under the GNU General Public License v2.0.
|
||||
Source: https://github.com/alerque/gnumicr
|
||||
Full license text: https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
Feb 2, 2004
|
||||
Hm, this version 0.30 has been sitting around for a while, put it out
|
||||
there! Several people have reported using this font with good success,
|
||||
even in commercial applications.
|
||||
|
||||
August 12, 2000
|
||||
I have someone who is willing to test the TTF version of this font in
|
||||
the real world, on a fairly large scale... we'll see how it goes!
|
||||
|
||||
The TTF version was just converted by a font program, so I have no
|
||||
idea if conversion errors were introduced.
|
||||
|
||||
July 1, 2000
|
||||
First Public Release
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
GNUMICR MICR / E13-B font
|
||||
|
||||
A PostScript(tm) Type 1 MICR Font released under the GPL.
|
||||
|
||||
Copyright (c) 2000-2003, Eric Sandeen <sandeen-gnumicr *at* sandeen *dot* net>
|
||||
|
||||
This font is released under the GNU General Public License ("GPL")
|
||||
(see the file COPYING for details).
|
||||
|
||||
Important details to note about the license:
|
||||
|
||||
1) This font comes with NO WARRANTY. I am not responsible for any
|
||||
damages or expenses resulting from its use.
|
||||
|
||||
2) This font may only be distributed with the license and the source code
|
||||
to the font intact. It's not exactly clear to me how the GNU GPL applies to
|
||||
fonts, but in my eyes, the font file "GnuMICR.raw" is the "source code" to
|
||||
this font, and the files "GnuMICR.pfa" and "GnuMICR.pfb" are the compiled
|
||||
versions. if you redistribute the "compiled" version, you must also
|
||||
distribute, or offer to distribute, the "source" version (see COPYING).
|
||||
|
||||
Also, it is my wish that this font not be distributed in such a way
|
||||
that it is built into a proprietary piece of software. I'll leave
|
||||
the legal wrangling to the lawyers, but in my opinion, if you
|
||||
write, say, a non-GPL'd check printing application for Windows,
|
||||
you should not hard-code or embed this font into your application.
|
||||
I feel that this would be the font-equivalent of linking libraries.
|
||||
If you wish to distribute this font with your app, that's fine,
|
||||
but I feel that it should be distributed alongside the application,
|
||||
with all copyright & license info intact, per the terms of the GPL.
|
||||
|
||||
I have spent MANY hours on this font. Please respect my work, and
|
||||
follow my wishes regarding licensing of this particular font.
|
||||
|
||||
For a brief introduction to fonts, copyright, and piracy, see
|
||||
"Ethics and Licensing Issues Related to Type" at
|
||||
http://www.linuxdoc.org/HOWTO/Font-HOWTO-12.html
|
||||
|
||||
--------------------------
|
||||
|
||||
Ok, with that out of the way...
|
||||
|
||||
A while ago, I set out to be able to print my own checks under Linux. The
|
||||
first requirement was that I find a MICR font (those funky numbers and
|
||||
symbols at the bottom of your check) that could be freely
|
||||
distributed with the application. I quickly found that such a thing didn't
|
||||
exist, so I set out to make my own. This font is the result.
|
||||
|
||||
I coded this font by hand, without the aid of any GUI font application.
|
||||
My goal was to be as accurate as possible, with complete control over
|
||||
the resulting font. I found dimensions and other specs for the font
|
||||
and their use at http://www.cdnpay.ca/eng/rules/006.ENG.htm
|
||||
This seems to be mostly geared towards Canadian standards, but the MICR
|
||||
font is an ISO standard, and the glyph dimensions should be the same
|
||||
as are used in the United States.
|
||||
|
||||
My sole source for information for this font was the URL above. This font
|
||||
is NOT a modified version of any existing MICR font.
|
||||
|
||||
The "source code" to this font is the file "GnuMICR.raw" This file
|
||||
may be converted into an actual Type 1 (.pfa or .pfb) font with
|
||||
the "t1utils" package available
|
||||
at http://www.lcdf.org/~eddietwo/type/#t1utils
|
||||
|
||||
I have not had this font tested by any bank. You should get best results
|
||||
with a 600dpi PostScript(tm) printer, probably worse results with a 300
|
||||
dpi laserjet printer via GhostScript. The hinting on the font is likely
|
||||
not perfect, so rendering at lower DPI may introduce some errors.
|
||||
|
||||
I have had some very good reports of people using this font for
|
||||
commercial printing, however.
|
||||
|
||||
I'm not a PostScript(tm) expert by any means. If anyone who is reading
|
||||
this document _is_ and would like to offer suggestions or patches for the
|
||||
font or its associated .afm file, please feel free to do so.
|
||||
|
||||
Also, if you have access to the tools needed to really test the font,
|
||||
and you feel like doing that, I would really like to hear from you.
|
||||
I'd like to make this the best MICR font available. I think it's
|
||||
well on its way - see "comparison.png" for an overlay of GnuMICR
|
||||
vs. a sample from a commercial font. Note the strange arcs in some
|
||||
regions of the commercial font.
|
||||
|
||||
The TTF font provided in this package was converted by a third
|
||||
party using Windows NT. As such, I have very little confidence
|
||||
in the quality of the TTF font. If you want the result of my hard
|
||||
work on dimensional accuracy, use the postscript font.
|
||||
|
||||
See the file INSTALL for help installing this font.
|
||||
|
||||
-Eric Sandeen
|
||||
July 1, 2000
|
||||
Updated March 2, 2003
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
||||
%!
|
||||
|
||||
/GnuMICR findfont
|
||||
12 scalefont
|
||||
setfont
|
||||
|
||||
newpath
|
||||
|
||||
100 500 moveto
|
||||
(0123456789 A B C D) show
|
||||
|
||||
stroke
|
||||
|
||||
showpage
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* import-mdb.js
|
||||
*
|
||||
* One-time migration: reads a single ezCheckPrinting .mdb file and imports
|
||||
* account config, check layout, and check records into the SQLite database.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - mdbtools installed: `sudo apt install mdbtools` or brew install mdbtools
|
||||
* - SQLite DB initialized (runs automatically on first require of database.js)
|
||||
*
|
||||
* Usage:
|
||||
* node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb"
|
||||
* node migrations/import-mdb.js --file "/path/to/Montana Dinosaur Center.mdb" --dry-run
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const db = require('../src/db/database');
|
||||
|
||||
// ---- CLI args ---------------------------------------------------------------
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fileIndex = args.indexOf('--file');
|
||||
if (fileIndex === -1 || !args[fileIndex + 1]) {
|
||||
console.error('Usage: node migrations/import-mdb.js --file "/path/to/Account.mdb"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mdbFile = args[fileIndex + 1];
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('[dry-run] No data will be written to the database.');
|
||||
}
|
||||
|
||||
// ---- mdbtools helpers -------------------------------------------------------
|
||||
|
||||
function mdbExport(table) {
|
||||
try {
|
||||
const output = execSync(`mdb-export "${mdbFile}" ${table}`, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
return parseCsv(output);
|
||||
} catch (err) {
|
||||
console.error(`Failed to export table ${table}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal CSV parser. Handles quoted fields with embedded commas and newlines.
|
||||
* Not a full RFC 4180 implementation but sufficient for mdb-export output.
|
||||
*/
|
||||
function parseCsv(text) {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headers = splitCsvLine(lines[0]);
|
||||
const rows = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = splitCsvLine(lines[i]);
|
||||
if (values.length === 0) continue;
|
||||
const row = {};
|
||||
headers.forEach((h, idx) => {
|
||||
row[h.trim()] = values[idx] !== undefined ? values[idx] : null;
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function splitCsvLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Font name normalization -------------------------------------------------
|
||||
// .mdb stores Windows font names; map common ones to PDFKit built-ins.
|
||||
// Any unmapped font will fall back to Helvetica at render time.
|
||||
const FONT_MAP = {
|
||||
'Times New Roman': 'Times-Roman',
|
||||
'Helsinki': 'Helvetica',
|
||||
'Arial': 'Helvetica',
|
||||
'Courier New': 'Courier',
|
||||
};
|
||||
|
||||
function normalizeFont(fontName, isBold) {
|
||||
const mapped = FONT_MAP[fontName] || 'Helvetica';
|
||||
if (isBold) {
|
||||
if (mapped === 'Times-Roman') return 'Times-Bold';
|
||||
if (mapped === 'Helvetica') return 'Helvetica-Bold';
|
||||
if (mapped === 'Courier') return 'Courier-Bold';
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// ---- Import: T100 (account config) ------------------------------------------
|
||||
|
||||
function importAccount() {
|
||||
console.log('\n--- Importing account config (T100) ---');
|
||||
const rows = mdbExport('T100');
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.error('No rows in T100. Is this a valid ezCheckPrinting .mdb?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Take the first (and typically only) row
|
||||
const r = rows[0];
|
||||
console.log(`Account: ${r.Company1} / Bank: ${r.BankName}`);
|
||||
console.log(`Routing: ${r.BankRouteNo} | Account: ${r.BankAccountNo}`);
|
||||
console.log(`Current check no: ${r.CurrentCheckNo}`);
|
||||
|
||||
// Logo is stored as base64 in the Settings table (not T100)
|
||||
// We fetch it separately below and update after insert.
|
||||
const accountData = {
|
||||
bank_name: r.BankName?.trim() || '',
|
||||
bank_info1: r.BankInfo1?.trim() || null,
|
||||
bank_info2: r.BankInfo2?.trim() || null,
|
||||
bank_info3: r.BankInfo3?.trim() || null,
|
||||
transit_code: r.TransitCode?.trim() || null,
|
||||
routing_number: r.BankRouteNo?.trim() || '',
|
||||
account_number: r.BankAccountNo?.trim() || '',
|
||||
start_check_no: parseInt(r.StartCheckNo) || 1000,
|
||||
current_check_no: parseInt(r.CurrentCheckNo) || 1000,
|
||||
check_width: parseFloat(r.CheckWidth) || 8.5,
|
||||
check_height: parseFloat(r.CheckHeight) || 3.5,
|
||||
offset_left: parseFloat(r.OffsetLeft) || 0,
|
||||
offset_right: parseFloat(r.OffsetRight) || 0,
|
||||
offset_up: parseFloat(r.OffsetUp) || 0,
|
||||
offset_down: parseFloat(r.OffsetDown) || 0,
|
||||
company1: r.Company1?.trim() || null,
|
||||
company2: r.Company2?.trim() || null,
|
||||
company3: r.Company3?.trim() || null,
|
||||
company4: r.Company4?.trim() || null,
|
||||
blank_stock: r.BlankBankStock === 'true' || r.BlankBankStock === '1' ? 1 : 0,
|
||||
check_position: r.ExField1?.trim() || '3-per-page',
|
||||
};
|
||||
|
||||
if (!dryRun) {
|
||||
// Delete existing account row (single-account Phase 1 assumption)
|
||||
db.prepare('DELETE FROM account').run();
|
||||
db.prepare(`
|
||||
INSERT INTO account (
|
||||
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
||||
routing_number, account_number, start_check_no, current_check_no,
|
||||
check_width, check_height, offset_left, offset_right, offset_up, offset_down,
|
||||
company1, company2, company3, company4,
|
||||
blank_stock, check_position
|
||||
) VALUES (
|
||||
@bank_name, @bank_info1, @bank_info2, @bank_info3, @transit_code,
|
||||
@routing_number, @account_number, @start_check_no, @current_check_no,
|
||||
@check_width, @check_height, @offset_left, @offset_right, @offset_up, @offset_down,
|
||||
@company1, @company2, @company3, @company4,
|
||||
@blank_stock, @check_position
|
||||
)
|
||||
`).run(accountData);
|
||||
console.log('Account config imported.');
|
||||
} else {
|
||||
console.log('[dry-run] Would insert:', JSON.stringify(accountData, null, 2));
|
||||
}
|
||||
|
||||
return accountData;
|
||||
}
|
||||
|
||||
// ---- Import: Settings (logo image) ------------------------------------------
|
||||
|
||||
function importLogo() {
|
||||
console.log('\n--- Importing logo from Settings table ---');
|
||||
const rows = mdbExport('Settings');
|
||||
const logoRow = rows.find(r => r.SettingKey === 'LogoImg');
|
||||
|
||||
if (!logoRow || !logoRow.SettingValue) {
|
||||
console.log('No logo found in Settings table.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Value is raw base64 (GIF format based on the data we saw)
|
||||
const base64Data = logoRow.SettingValue.trim();
|
||||
const dataUri = `data:image/gif;base64,${base64Data}`;
|
||||
|
||||
if (!dryRun) {
|
||||
db.prepare('UPDATE account SET logo_data = ? WHERE id = 1').run(dataUri);
|
||||
console.log(`Logo imported (${Math.round(base64Data.length / 1024)} KB base64).`);
|
||||
} else {
|
||||
console.log(`[dry-run] Would import logo (${Math.round(base64Data.length / 1024)} KB base64).`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Import: T200 (check layout fields) -------------------------------------
|
||||
|
||||
function importLayoutFields() {
|
||||
console.log('\n--- Importing check layout fields (T200) ---');
|
||||
const rows = mdbExport('T200');
|
||||
console.log(`Found ${rows.length} layout fields.`);
|
||||
|
||||
if (!dryRun) {
|
||||
db.prepare('DELETE FROM layout_fields').run();
|
||||
}
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO layout_fields (
|
||||
field_name, field_text, font_name, font_size, font_bold,
|
||||
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos,
|
||||
visible, not_for_preprint
|
||||
) VALUES (
|
||||
@field_name, @field_text, @font_name, @font_size, @font_bold,
|
||||
@field_type, @line_thick, @x_pos, @y_pos, @x_end_pos, @y_end_pos,
|
||||
@visible, @not_for_preprint
|
||||
)
|
||||
`);
|
||||
|
||||
let count = 0;
|
||||
for (const r of rows) {
|
||||
const isBold = r.FldFontType === '1';
|
||||
const fieldData = {
|
||||
field_name: r.FldName?.trim() || '',
|
||||
field_text: r.FldText?.trim() || null,
|
||||
font_name: normalizeFont(r.FldFontName?.trim(), isBold),
|
||||
font_size: parseFloat(r.FldFontSize) || 10,
|
||||
font_bold: isBold ? 1 : 0,
|
||||
field_type: r.FldType?.trim() || 'Regular',
|
||||
line_thick: parseInt(r.LnThick) || 1,
|
||||
x_pos: parseFloat(r.XPos) || 0,
|
||||
y_pos: parseFloat(r.YPos) || 0,
|
||||
x_end_pos: parseFloat(r.XEndPos) || 0,
|
||||
y_end_pos: parseFloat(r.YEndPos) || 0,
|
||||
visible: r.Display === '1' ? 1 : 0,
|
||||
not_for_preprint: parseInt(r.NotForPreprint) || 0,
|
||||
};
|
||||
|
||||
if (!dryRun) {
|
||||
insert.run(fieldData);
|
||||
} else {
|
||||
console.log(` [dry-run] ${fieldData.field_name}: type=${fieldData.field_type} x=${fieldData.x_pos} y=${fieldData.y_pos}`);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? '[dry-run] Would import' : 'Imported'} ${count} layout fields.`);
|
||||
}
|
||||
|
||||
// ---- Import: T104 (check records) -------------------------------------------
|
||||
|
||||
function importChecks() {
|
||||
console.log('\n--- Importing check records (T104) ---');
|
||||
const rows = mdbExport('T104');
|
||||
console.log(`Found ${rows.length} check records.`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('No checks to import.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
db.prepare('DELETE FROM checks').run();
|
||||
}
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO checks (
|
||||
check_no, payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4,
|
||||
printed, add_date, mdb_check_id
|
||||
) VALUES (
|
||||
@check_no, @payee, @amount, @check_date, @memo, @note1, @note2,
|
||||
@payee_address1, @payee_address2, @payee_address3, @payee_address4,
|
||||
@printed, @add_date, @mdb_check_id
|
||||
)
|
||||
`);
|
||||
|
||||
let count = 0;
|
||||
let skipped = 0;
|
||||
for (const r of rows) {
|
||||
// Normalize date: .mdb uses MM/DD/YYYY or similar; convert to YYYY-MM-DD
|
||||
const rawDate = r.CheckDate?.trim() || '';
|
||||
const checkDate = normalizeDate(rawDate);
|
||||
|
||||
const addDate = normalizeDate(r.AddDate?.trim() || '') || new Date().toISOString();
|
||||
|
||||
const checkData = {
|
||||
check_no: parseInt(r.CheckNo),
|
||||
payee: r.Payee?.trim() || '',
|
||||
amount: parseFloat(r.Amount) || 0,
|
||||
check_date: checkDate,
|
||||
memo: r.Memo?.trim() || null,
|
||||
note1: r.Note1?.trim() || null,
|
||||
note2: r.Note2?.trim() || null,
|
||||
payee_address1: r.PayeeAddress1?.trim() || null,
|
||||
payee_address2: r.PayeeAddress2?.trim() || null,
|
||||
payee_address3: r.PayeeAddress3?.trim() || null,
|
||||
payee_address4: r.PayeeAddress4?.trim() || null,
|
||||
printed: r.checked === 'true' || r.checked === '1' ? 1 : 0,
|
||||
add_date: addDate,
|
||||
mdb_check_id: parseInt(r.CheckID) || null,
|
||||
};
|
||||
|
||||
if (!checkData.check_no || !checkData.payee) {
|
||||
console.warn(` Skipping record with missing check_no or payee:`, r);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
try {
|
||||
insert.run(checkData);
|
||||
} catch (err) {
|
||||
console.warn(` Skipping duplicate check #${checkData.check_no}:`, err.message);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.log(` [dry-run] Check #${checkData.check_no}: ${checkData.payee} $${checkData.amount} ${checkData.check_date}`);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? '[dry-run] Would import' : 'Imported'} ${count} checks.${skipped > 0 ? ` Skipped ${skipped}.` : ''}`);
|
||||
}
|
||||
|
||||
// ---- Date normalization -----------------------------------------------------
|
||||
|
||||
function normalizeDate(raw) {
|
||||
if (!raw) return null;
|
||||
// mdb-export outputs dates as "MM/DD/YYYY HH:MM:SS" or "YYYY-MM-DD"
|
||||
const mdyMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
|
||||
if (mdyMatch) {
|
||||
const [, m, d, y] = mdyMatch;
|
||||
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||
}
|
||||
const isoMatch = raw.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (isoMatch) return isoMatch[1];
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ---- Run --------------------------------------------------------------------
|
||||
|
||||
console.log(`\nImporting from: ${mdbFile}`);
|
||||
console.log(`Target database: ${process.env.DB_PATH || 'data/ezcheck.db'}`);
|
||||
|
||||
try {
|
||||
importAccount();
|
||||
importLogo();
|
||||
importLayoutFields();
|
||||
importChecks();
|
||||
console.log('\nMigration complete.');
|
||||
} catch (err) {
|
||||
console.error('\nMigration failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ezcheck",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-hosted check printing web app",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js",
|
||||
"migrate": "node migrations/import-mdb.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"express": "^4.18.3",
|
||||
"pdfkit": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #d0d0d0;
|
||||
--header-bg: #1a2b3c;
|
||||
--header-fg: #ffffff;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #6b7280;
|
||||
--row-hover: #f0f4ff;
|
||||
--panel-width: 420px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: var(--header-fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-brand { font-size: 15px; font-weight: 600; }
|
||||
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
.header-info strong { color: #fff; }
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 1rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
.toolbar label { font-size: 12px; font-weight: 500; color: var(--text-muted); }
|
||||
|
||||
select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
padding: 5px 12px;
|
||||
line-height: 1.4;
|
||||
transition: background 0.1s, opacity 0.1s;
|
||||
}
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: var(--primary); color: #fff; font-weight: 500; }
|
||||
.btn-primary:not(:disabled):hover { background: var(--primary-hover); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
.btn-ghost { background: transparent; color: var(--text-muted); }
|
||||
.btn-ghost:hover { color: var(--text); background: var(--bg); }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 22px;
|
||||
padding: 0 6px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-icon:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-edit { background: transparent; color: var(--primary); }
|
||||
.btn-edit:hover { background: #eff6ff; }
|
||||
.btn-delete { background: transparent; color: var(--danger); }
|
||||
.btn-delete:hover { background: #fee2e2; }
|
||||
.btn-reprint { background: transparent; color: var(--text-muted); }
|
||||
.btn-reprint:hover { background: var(--bg); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
thead th.sortable { cursor: pointer; }
|
||||
thead th.sortable:hover { color: var(--text); }
|
||||
thead th.sort-asc .sort-indicator::after { content: ' ↑'; }
|
||||
thead th.sort-desc .sort-indicator::after { content: ' ↓'; }
|
||||
|
||||
tbody tr { border-bottom: 1px solid #f0f0f0; }
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
tbody tr.printed { color: var(--text-muted); }
|
||||
|
||||
td {
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-select { width: 32px; }
|
||||
.col-no { width: 60px; font-variant-numeric: tabular-nums; }
|
||||
.col-date { width: 95px; white-space: nowrap; }
|
||||
.col-amount { width: 95px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||
.col-memo { color: var(--text-muted); font-size: 12px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.col-status { width: 88px; text-align: center; }
|
||||
.col-actions { width: 130px; text-align: right; white-space: nowrap; }
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.status-unprinted { background: #fef3c7; color: #92400e; }
|
||||
.status-printed { background: #e0f2fe; color: #0369a1; }
|
||||
|
||||
.loading-row td,
|
||||
.empty-row td { text-align: center; padding: 2rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Slide-in panel ── */
|
||||
#panel-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
#panel-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
#check-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--panel-width);
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
z-index: 101;
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#check-panel.open { transform: translateX(0); }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-fg);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.panel-header h2 { font-size: 14px; font-weight: 600; }
|
||||
|
||||
/* ── Form ── */
|
||||
#check-form {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.form-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.form-group.required label::after { content: ' *'; color: var(--danger); }
|
||||
|
||||
.form-group input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(37,99,235,0.15);
|
||||
}
|
||||
.form-group input.error { border-color: var(--danger); }
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.address-section summary {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
.address-section summary::before { content: '▶ '; font-size: 9px; }
|
||||
.address-section[open] summary::before { content: '▼ '; }
|
||||
.address-section[open] summary { border-bottom: 1px solid var(--border); }
|
||||
.address-fields {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: auto;
|
||||
}
|
||||
.form-actions .btn-primary { flex: 1; padding: 8px; }
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ezcheck</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-brand">
|
||||
<span id="company-name">ezcheck</span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
Next check: <strong id="current-check-no">—</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<label for="filter-status">Show:</label>
|
||||
<select id="filter-status">
|
||||
<option value="">All</option>
|
||||
<option value="0" selected>Unprinted</option>
|
||||
<option value="1">Printed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button id="btn-generate-pdf" class="btn-primary" disabled>
|
||||
Generate PDF <span id="selected-count" class="badge">0</span>
|
||||
</button>
|
||||
<button id="btn-new-check" class="btn-secondary">+ New Check</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="checks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-select"></th>
|
||||
<th class="col-no sortable" data-col="check_no"># <span class="sort-indicator"></span></th>
|
||||
<th class="col-date sortable" data-col="check_date">Date <span class="sort-indicator"></span></th>
|
||||
<th class="col-payee sortable" data-col="payee">Payee <span class="sort-indicator"></span></th>
|
||||
<th class="col-amount sortable" data-col="amount">Amount <span class="sort-indicator"></span></th>
|
||||
<th class="col-memo">Memo</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checks-tbody">
|
||||
<tr class="loading-row"><td colspan="8">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Slide-in panel -->
|
||||
<div id="panel-overlay"></div>
|
||||
<aside id="check-panel">
|
||||
<div class="panel-header">
|
||||
<h2 id="panel-title">New Check</h2>
|
||||
<button id="btn-close-panel" class="btn-icon" title="Close">×</button>
|
||||
</div>
|
||||
<form id="check-form" novalidate>
|
||||
<div class="form-group required">
|
||||
<label for="f-payee">Payee</label>
|
||||
<input type="text" id="f-payee" name="payee" required autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group required">
|
||||
<label for="f-amount">Amount ($)</label>
|
||||
<input type="number" id="f-amount" name="amount" required min="0.01" step="0.01" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group required">
|
||||
<label for="f-date">Date</label>
|
||||
<input type="date" id="f-date" name="check_date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="f-memo">Memo</label>
|
||||
<input type="text" id="f-memo" name="memo">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="f-note1">Note 1</label>
|
||||
<input type="text" id="f-note1" name="note1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="f-note2">Note 2</label>
|
||||
<input type="text" id="f-note2" name="note2">
|
||||
</div>
|
||||
</div>
|
||||
<details class="address-section">
|
||||
<summary>Payee Address</summary>
|
||||
<div class="address-fields">
|
||||
<div class="form-group">
|
||||
<label for="f-addr1">Line 1</label>
|
||||
<input type="text" id="f-addr1" name="payee_address1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="f-addr2">Line 2</label>
|
||||
<input type="text" id="f-addr2" name="payee_address2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="f-addr3">Line 3</label>
|
||||
<input type="text" id="f-addr3" name="payee_address3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="f-addr4">Line 4</label>
|
||||
<input type="text" id="f-addr4" name="payee_address4">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" id="btn-save">Save Check</button>
|
||||
<button type="button" class="btn-ghost" id="btn-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,391 @@
|
||||
'use strict';
|
||||
|
||||
const state = {
|
||||
checks: [],
|
||||
account: null,
|
||||
filter: '0', // '' = all, '0' = unprinted, '1' = printed
|
||||
sortCol: 'check_no',
|
||||
sortDir: 'desc',
|
||||
selected: new Set(),
|
||||
editingId: null,
|
||||
};
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAccount() {
|
||||
try {
|
||||
state.account = await apiFetch('GET', '/api/account');
|
||||
renderHeader();
|
||||
} catch {
|
||||
// account not configured yet — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChecks() {
|
||||
const tbody = document.getElementById('checks-tbody');
|
||||
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (state.filter !== '') params.set('printed', state.filter);
|
||||
state.checks = await apiFetch('GET', `/api/checks?${params}`);
|
||||
state.selected.clear();
|
||||
renderTable();
|
||||
refreshPdfButton();
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr class="empty-row"><td colspan="8">Error loading checks: ${escHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderHeader() {
|
||||
const a = state.account;
|
||||
if (!a) return;
|
||||
document.getElementById('company-name').textContent = a.company1 || 'ezcheck';
|
||||
document.getElementById('current-check-no').textContent = (a.current_check_no + 1).toLocaleString();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const checks = sortedChecks();
|
||||
const tbody = document.getElementById('checks-tbody');
|
||||
|
||||
if (checks.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No checks found.</td></tr>';
|
||||
updateSortIndicators();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = checks.map(renderRow).join('');
|
||||
updateSortIndicators();
|
||||
updateCheckboxStates();
|
||||
|
||||
// Attach row-level event listeners
|
||||
tbody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => onCheckboxChange(cb));
|
||||
});
|
||||
tbody.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => openPanel(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
tbody.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteCheck(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
tbody.querySelectorAll('.btn-reprint').forEach(btn => {
|
||||
btn.addEventListener('click', () => reprintCheck(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(c) {
|
||||
const printed = !!c.printed;
|
||||
const selected = state.selected.has(c.id);
|
||||
|
||||
const fmtAmount = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency', currency: 'USD',
|
||||
}).format(c.amount);
|
||||
|
||||
const fmtDate = c.check_date
|
||||
? new Date(c.check_date + 'T12:00:00').toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
: '—';
|
||||
|
||||
const checkbox = printed
|
||||
? '<td class="col-select"></td>'
|
||||
: `<td class="col-select"><input type="checkbox" data-id="${c.id}"${selected ? ' checked' : ''}></td>`;
|
||||
|
||||
const statusBadge = printed
|
||||
? '<span class="status-badge status-printed">Printed</span>'
|
||||
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
||||
|
||||
const actions = printed
|
||||
? `<button class="btn-sm btn-reprint" data-id="${c.id}">Reprint</button>`
|
||||
: `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
|
||||
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`;
|
||||
|
||||
return `<tr class="${printed ? 'printed' : ''}">
|
||||
${checkbox}
|
||||
<td class="col-no">${c.check_no}</td>
|
||||
<td class="col-date">${fmtDate}</td>
|
||||
<td class="col-payee">${escHtml(c.payee)}</td>
|
||||
<td class="col-amount">${fmtAmount}</td>
|
||||
<td class="col-memo" title="${escHtml(c.memo || '')}">${escHtml(c.memo || '')}</td>
|
||||
<td class="col-status">${statusBadge}</td>
|
||||
<td class="col-actions">${actions}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function sortedChecks() {
|
||||
const col = state.sortCol;
|
||||
const dir = state.sortDir === 'asc' ? 1 : -1;
|
||||
return [...state.checks].sort((a, b) => {
|
||||
let av = a[col];
|
||||
let bv = b[col];
|
||||
if (col === 'amount') { av = parseFloat(av); bv = parseFloat(bv); }
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
if (av < bv) return -dir;
|
||||
if (av > bv) return dir;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
document.querySelectorAll('thead th.sortable').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
if (th.dataset.col === state.sortCol) {
|
||||
th.classList.add(state.sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCheckboxStates() {
|
||||
document.querySelectorAll('#checks-tbody input[type="checkbox"]').forEach(cb => {
|
||||
const id = parseInt(cb.dataset.id, 10);
|
||||
if (!state.selected.has(id)) {
|
||||
cb.disabled = state.selected.size >= 3;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPdfButton() {
|
||||
const n = state.selected.size;
|
||||
const btn = document.getElementById('btn-generate-pdf');
|
||||
btn.disabled = n === 0;
|
||||
document.getElementById('selected-count').textContent = n;
|
||||
}
|
||||
|
||||
// ── Checkbox handling ────────────────────────────────────────────────────────
|
||||
|
||||
function onCheckboxChange(cb) {
|
||||
const id = parseInt(cb.dataset.id, 10);
|
||||
if (cb.checked) {
|
||||
if (state.selected.size >= 3) {
|
||||
cb.checked = false;
|
||||
return;
|
||||
}
|
||||
state.selected.add(id);
|
||||
} else {
|
||||
state.selected.delete(id);
|
||||
}
|
||||
refreshPdfButton();
|
||||
updateCheckboxStates();
|
||||
}
|
||||
|
||||
// ── Slide-in panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function openPanel(id = null) {
|
||||
state.editingId = id;
|
||||
const form = document.getElementById('check-form');
|
||||
const title = document.getElementById('panel-title');
|
||||
|
||||
form.reset();
|
||||
clearFormErrors();
|
||||
document.querySelector('.address-section').removeAttribute('open');
|
||||
|
||||
if (id !== null) {
|
||||
const check = state.checks.find(c => c.id === id);
|
||||
if (!check) return;
|
||||
title.textContent = `Edit Check #${check.check_no}`;
|
||||
form.payee.value = check.payee || '';
|
||||
form.amount.value = check.amount != null ? check.amount : '';
|
||||
form.check_date.value = check.check_date || '';
|
||||
form.memo.value = check.memo || '';
|
||||
form.note1.value = check.note1 || '';
|
||||
form.note2.value = check.note2 || '';
|
||||
form.payee_address1.value = check.payee_address1 || '';
|
||||
form.payee_address2.value = check.payee_address2 || '';
|
||||
form.payee_address3.value = check.payee_address3 || '';
|
||||
form.payee_address4.value = check.payee_address4 || '';
|
||||
if (check.payee_address1) {
|
||||
document.querySelector('.address-section').setAttribute('open', '');
|
||||
}
|
||||
} else {
|
||||
title.textContent = 'New Check';
|
||||
form.check_date.value = new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
document.getElementById('panel-overlay').classList.add('open');
|
||||
document.getElementById('check-panel').classList.add('open');
|
||||
form.payee.focus();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
document.getElementById('panel-overlay').classList.remove('open');
|
||||
document.getElementById('check-panel').classList.remove('open');
|
||||
state.editingId = null;
|
||||
}
|
||||
|
||||
function clearFormErrors() {
|
||||
document.querySelectorAll('#check-form .error').forEach(el => el.classList.remove('error'));
|
||||
}
|
||||
|
||||
// ── CRUD actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveCheck(e) {
|
||||
e.preventDefault();
|
||||
clearFormErrors();
|
||||
|
||||
const form = e.target;
|
||||
const data = {
|
||||
payee: form.payee.value.trim(),
|
||||
amount: parseFloat(form.amount.value),
|
||||
check_date: form.check_date.value,
|
||||
memo: form.memo.value.trim() || null,
|
||||
note1: form.note1.value.trim() || null,
|
||||
note2: form.note2.value.trim() || null,
|
||||
payee_address1: form.payee_address1.value.trim() || null,
|
||||
payee_address2: form.payee_address2.value.trim() || null,
|
||||
payee_address3: form.payee_address3.value.trim() || null,
|
||||
payee_address4: form.payee_address4.value.trim() || null,
|
||||
};
|
||||
|
||||
let valid = true;
|
||||
if (!data.payee) { form.payee.classList.add('error'); valid = false; }
|
||||
if (!data.amount || isNaN(data.amount) || data.amount <= 0) { form.amount.classList.add('error'); valid = false; }
|
||||
if (!data.check_date) { form.check_date.classList.add('error'); valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
const btn = document.getElementById('btn-save');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
|
||||
try {
|
||||
if (state.editingId !== null) {
|
||||
await apiFetch('PUT', `/api/checks/${state.editingId}`, data);
|
||||
} else {
|
||||
await apiFetch('POST', '/api/checks', data);
|
||||
}
|
||||
closePanel();
|
||||
await Promise.all([loadAccount(), loadChecks()]);
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save Check';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCheck(id) {
|
||||
const check = state.checks.find(c => c.id === id);
|
||||
if (!check) return;
|
||||
if (!confirm(`Delete check #${check.check_no} payable to "${check.payee}"?`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/api/checks/${id}`);
|
||||
await loadChecks();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf() {
|
||||
const ids = [...state.selected];
|
||||
if (ids.length === 0 || ids.length > 3) return;
|
||||
|
||||
const btn = document.getElementById('btn-generate-pdf');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkIds: ids }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
await loadChecks(); // refresh to show printed status
|
||||
} catch (err) {
|
||||
alert(`PDF error: ${err.message}`);
|
||||
} finally {
|
||||
refreshPdfButton();
|
||||
}
|
||||
}
|
||||
|
||||
async function reprintCheck(id) {
|
||||
const check = state.checks.find(c => c.id === id);
|
||||
if (!check) return;
|
||||
if (!confirm(`Reprint check #${check.check_no} to "${check.payee}"?\n(Will not re-mark as printed)`)) return;
|
||||
try {
|
||||
const res = await fetch('/api/pdf?mark_printed=false', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkIds: [id] }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
} catch (err) {
|
||||
alert(`Reprint error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
// Column sort
|
||||
document.querySelectorAll('thead th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
if (state.sortCol === th.dataset.col) {
|
||||
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
state.sortCol = th.dataset.col;
|
||||
state.sortDir = th.dataset.col === 'check_no' ? 'desc' : 'asc';
|
||||
}
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
// Filter dropdown
|
||||
document.getElementById('filter-status').addEventListener('change', e => {
|
||||
state.filter = e.target.value;
|
||||
loadChecks();
|
||||
});
|
||||
|
||||
// New check
|
||||
document.getElementById('btn-new-check').addEventListener('click', () => openPanel());
|
||||
|
||||
// Panel close
|
||||
document.getElementById('btn-close-panel').addEventListener('click', closePanel);
|
||||
document.getElementById('btn-cancel').addEventListener('click', closePanel);
|
||||
document.getElementById('panel-overlay').addEventListener('click', closePanel);
|
||||
|
||||
// Form submit
|
||||
document.getElementById('check-form').addEventListener('submit', saveCheck);
|
||||
|
||||
// Generate PDF
|
||||
document.getElementById('btn-generate-pdf').addEventListener('click', generatePdf);
|
||||
|
||||
// Initial data load
|
||||
loadAccount();
|
||||
loadChecks();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Routes
|
||||
app.use('/api/checks', require('./routes/checks'));
|
||||
app.use('/api/pdf', require('./routes/pdf'));
|
||||
|
||||
// Account info endpoint (read-only for Phase 1)
|
||||
app.get('/api/account', (req, res) => {
|
||||
const db = require('./db/database');
|
||||
const account = db.prepare(
|
||||
'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' +
|
||||
'routing_number, account_number, current_check_no, ' +
|
||||
'company1, company2, company3, company4, check_position FROM account WHERE id = 1'
|
||||
).get();
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'No account configured. Run migration first.' });
|
||||
}
|
||||
// Never send routing/account numbers in cleartext to the browser in production.
|
||||
// For local-only Phase 1 this is acceptable; redact for any network-exposed deployment.
|
||||
res.json(account);
|
||||
});
|
||||
|
||||
// Catch-all: serve index.html for client-side routing
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`ezcheck running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/ezcheck.db');
|
||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Initialize schema on first run
|
||||
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,82 @@
|
||||
-- ezcheck SQLite schema
|
||||
-- Mirrors .mdb structure (T100, T104, T200) with readable column names.
|
||||
-- One account per database is the Phase 1 assumption.
|
||||
-- Phase 2 will add foreign keys and an account switcher.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id INTEGER PRIMARY KEY,
|
||||
bank_name TEXT NOT NULL,
|
||||
bank_info1 TEXT,
|
||||
bank_info2 TEXT,
|
||||
bank_info3 TEXT,
|
||||
transit_code TEXT,
|
||||
routing_number TEXT NOT NULL,
|
||||
account_number TEXT NOT NULL,
|
||||
start_check_no INTEGER NOT NULL DEFAULT 1000,
|
||||
current_check_no INTEGER NOT NULL DEFAULT 1000,
|
||||
check_width REAL NOT NULL DEFAULT 8.5,
|
||||
check_height REAL NOT NULL DEFAULT 3.5,
|
||||
-- Per-check offset adjustments in inches (for printer calibration)
|
||||
offset_left REAL NOT NULL DEFAULT 0,
|
||||
offset_right REAL NOT NULL DEFAULT 0,
|
||||
offset_up REAL NOT NULL DEFAULT 0,
|
||||
offset_down REAL NOT NULL DEFAULT 0,
|
||||
-- Company info lines (printed top-left of check)
|
||||
company1 TEXT,
|
||||
company2 TEXT,
|
||||
company3 TEXT,
|
||||
company4 TEXT,
|
||||
-- Images stored as base64 data URIs
|
||||
logo_data TEXT,
|
||||
signature_data TEXT,
|
||||
-- Metadata
|
||||
blank_stock INTEGER NOT NULL DEFAULT 1, -- 1 = blank check stock
|
||||
check_position TEXT NOT NULL DEFAULT '3-per-page',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
check_no INTEGER NOT NULL,
|
||||
payee TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
check_date TEXT NOT NULL, -- ISO date string YYYY-MM-DD
|
||||
memo TEXT,
|
||||
note1 TEXT,
|
||||
note2 TEXT,
|
||||
payee_address1 TEXT,
|
||||
payee_address2 TEXT,
|
||||
payee_address3 TEXT,
|
||||
payee_address4 TEXT,
|
||||
printed INTEGER NOT NULL DEFAULT 0, -- 0 = not printed, 1 = printed
|
||||
add_date TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- original .mdb CheckID preserved if migrated, null if created in app
|
||||
mdb_check_id INTEGER,
|
||||
UNIQUE(check_no)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS layout_fields (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
field_name TEXT NOT NULL UNIQUE,
|
||||
field_text TEXT, -- static label text (for Text type fields)
|
||||
font_name TEXT NOT NULL DEFAULT 'Helvetica',
|
||||
font_size REAL NOT NULL DEFAULT 10,
|
||||
-- FldFontType from .mdb: 0=normal, 1=bold
|
||||
font_bold INTEGER NOT NULL DEFAULT 0,
|
||||
-- FldType: 'Regular' (data), 'Text' (static label), 'Graph' (image), 'Line'
|
||||
field_type TEXT NOT NULL DEFAULT 'Regular',
|
||||
line_thick INTEGER NOT NULL DEFAULT 1,
|
||||
x_pos REAL NOT NULL DEFAULT 0,
|
||||
y_pos REAL NOT NULL DEFAULT 0,
|
||||
x_end_pos REAL NOT NULL DEFAULT 0,
|
||||
y_end_pos REAL NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
-- 1 = only used on blank stock (not preprinted). We always render these.
|
||||
not_for_preprint INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Index for fast ledger queries
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_date ON checks(check_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_printed ON checks(printed);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_check_no ON checks(check_no);
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db/database');
|
||||
|
||||
// GET /api/checks - list all checks, newest first
|
||||
router.get('/', (req, res) => {
|
||||
const { after, printed } = req.query;
|
||||
let query = 'SELECT * FROM checks';
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
|
||||
if (after) {
|
||||
conditions.push('check_date >= ?');
|
||||
params.push(after);
|
||||
}
|
||||
if (printed !== undefined) {
|
||||
conditions.push('printed = ?');
|
||||
params.push(printed === 'true' || printed === '1' ? 1 : 0);
|
||||
}
|
||||
|
||||
if (conditions.length) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
query += ' ORDER BY check_no DESC';
|
||||
|
||||
const checks = db.prepare(query).all(...params);
|
||||
res.json(checks);
|
||||
});
|
||||
|
||||
// GET /api/checks/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
res.json(check);
|
||||
});
|
||||
|
||||
// POST /api/checks - create a new check
|
||||
router.post('/', (req, res) => {
|
||||
const { payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||
|
||||
if (!payee || !amount || !check_date) {
|
||||
return res.status(400).json({ error: 'payee, amount, and check_date are required' });
|
||||
}
|
||||
|
||||
// Get next check number from account
|
||||
const account = db.prepare('SELECT current_check_no FROM account WHERE id = 1').get();
|
||||
if (!account) return res.status(500).json({ error: 'No account configured. Run migration first.' });
|
||||
|
||||
const checkNo = account.current_check_no + 1;
|
||||
|
||||
const insertCheck = db.prepare(`
|
||||
INSERT INTO checks (check_no, payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const updateAccountCheckNo = db.prepare(
|
||||
'UPDATE account SET current_check_no = ?, updated_at = datetime(\'now\') WHERE id = 1'
|
||||
);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const result = insertCheck.run(
|
||||
checkNo, payee, parseFloat(amount), check_date,
|
||||
memo || null, note1 || null, note2 || null,
|
||||
payee_address1 || null, payee_address2 || null,
|
||||
payee_address3 || null, payee_address4 || null
|
||||
);
|
||||
updateAccountCheckNo.run(checkNo);
|
||||
return result.lastInsertRowid;
|
||||
});
|
||||
|
||||
const newId = transaction();
|
||||
const newCheck = db.prepare('SELECT * FROM checks WHERE id = ?').get(newId);
|
||||
res.status(201).json(newCheck);
|
||||
});
|
||||
|
||||
// PUT /api/checks/:id - update a check
|
||||
router.put('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
|
||||
if (check.printed) {
|
||||
return res.status(409).json({ error: 'Cannot edit a check that has been printed.' });
|
||||
}
|
||||
|
||||
const { payee, amount, check_date, memo, note1, note2,
|
||||
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE checks SET
|
||||
payee = ?, amount = ?, check_date = ?, memo = ?, note1 = ?, note2 = ?,
|
||||
payee_address1 = ?, payee_address2 = ?, payee_address3 = ?, payee_address4 = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payee ?? check.payee,
|
||||
amount !== undefined ? parseFloat(amount) : check.amount,
|
||||
check_date ?? check.check_date,
|
||||
memo ?? check.memo,
|
||||
note1 ?? check.note1,
|
||||
note2 ?? check.note2,
|
||||
payee_address1 ?? check.payee_address1,
|
||||
payee_address2 ?? check.payee_address2,
|
||||
payee_address3 ?? check.payee_address3,
|
||||
payee_address4 ?? check.payee_address4,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json(db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
// DELETE /api/checks/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||
|
||||
if (check.printed) {
|
||||
return res.status(409).json({ error: 'Cannot delete a check that has been printed.' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// POST /api/checks/mark-printed - mark checks as printed
|
||||
router.post('/mark-printed', (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'ids array required' });
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...ids);
|
||||
res.json({ updated: ids.length });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db/database');
|
||||
const { generateCheckPdf } = require('../services/pdfService');
|
||||
|
||||
/**
|
||||
* POST /api/pdf
|
||||
* Body: { checkIds: [1, 2, 3] } -- 1 to 3 check IDs
|
||||
*
|
||||
* Returns a PDF with 1–3 checks in a 3-up layout.
|
||||
* After successful generation, marks all checks as printed.
|
||||
*
|
||||
* Query param: ?mark_printed=false to suppress auto-marking (for reprints).
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
const { checkIds } = req.body;
|
||||
|
||||
if (!Array.isArray(checkIds) || checkIds.length === 0 || checkIds.length > 3) {
|
||||
return res.status(400).json({ error: 'checkIds must be an array of 1–3 IDs' });
|
||||
}
|
||||
|
||||
// Fetch account
|
||||
const account = db.prepare('SELECT * FROM account WHERE id = 1').get();
|
||||
if (!account) {
|
||||
return res.status(500).json({ error: 'No account configured. Run migration first.' });
|
||||
}
|
||||
|
||||
// Fetch checks in the order provided
|
||||
const checks = checkIds.map(id => {
|
||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(id);
|
||||
if (!check) throw new Error(`Check ID ${id} not found`);
|
||||
return check;
|
||||
});
|
||||
|
||||
// Fetch layout fields (all visible fields)
|
||||
const fields = db.prepare('SELECT * FROM layout_fields WHERE visible = 1').all();
|
||||
|
||||
try {
|
||||
const pdfBuffer = await generateCheckPdf(account, checks, fields);
|
||||
|
||||
// Mark as printed unless explicitly suppressed (e.g., reprint)
|
||||
const markPrinted = req.query.mark_printed !== 'false';
|
||||
if (markPrinted) {
|
||||
const placeholders = checkIds.map(() => '?').join(',');
|
||||
db.prepare(`UPDATE checks SET printed = 1 WHERE id IN (${placeholders})`).run(...checkIds);
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="checks-${checkIds.join('-')}.pdf"`,
|
||||
'Content-Length': pdfBuffer.length,
|
||||
});
|
||||
res.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err);
|
||||
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,292 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* pdfService.js
|
||||
*
|
||||
* Generates a 3-up check PDF from 1–3 check records.
|
||||
* All measurements are in points (72 pts/inch) internally;
|
||||
* layout coordinates from the database are in inches and converted here.
|
||||
*
|
||||
* Page layout:
|
||||
* - 8.5" × 11" letter page
|
||||
* - Three check slots: each 8.5" wide × 3.667" tall
|
||||
* - MICR line: hardcoded at Y = 3.4" from top of each slot
|
||||
*
|
||||
* Coordinate origin for each slot is top-left of that slot.
|
||||
*/
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const POINTS_PER_INCH = 72;
|
||||
const PAGE_WIDTH_IN = 8.5;
|
||||
const PAGE_HEIGHT_IN = 11;
|
||||
const SLOT_HEIGHT_IN = PAGE_HEIGHT_IN / 3; // 3.667"
|
||||
const MICR_Y_IN = SLOT_HEIGHT_IN - 0.267; // 0.267" from bottom of slot
|
||||
|
||||
// MICR line format: transit symbol (⑆) and on-us symbol (⑈) in E-13B encoding.
|
||||
// The GnuMICR / micrenc font maps these to specific characters.
|
||||
// Standard MICR layout: [check#] ⑆[routing]⑆ [account#]⑈
|
||||
const MICR_FONT_PATH = process.env.MICR_FONT_PATH
|
||||
? path.resolve(process.env.MICR_FONT_PATH)
|
||||
: path.join(__dirname, '../../fonts/micrenc.ttf');
|
||||
|
||||
// Amount in words conversion
|
||||
function amountToWords(amount) {
|
||||
const dollars = Math.floor(amount);
|
||||
const cents = Math.round((amount - dollars) * 100);
|
||||
|
||||
const ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven',
|
||||
'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen',
|
||||
'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
|
||||
const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty',
|
||||
'Sixty', 'Seventy', 'Eighty', 'Ninety'];
|
||||
|
||||
function below1000(n) {
|
||||
if (n === 0) return '';
|
||||
if (n < 20) return ones[n] + ' ';
|
||||
if (n < 100) return tens[Math.floor(n / 10)] + (n % 10 ? '-' + ones[n % 10] : '') + ' ';
|
||||
return ones[Math.floor(n / 100)] + ' Hundred ' + below1000(n % 100);
|
||||
}
|
||||
|
||||
function toWords(n) {
|
||||
if (n === 0) return 'Zero';
|
||||
let result = '';
|
||||
if (Math.floor(n / 1000) > 0) {
|
||||
result += below1000(Math.floor(n / 1000)) + 'Thousand ';
|
||||
n = n % 1000;
|
||||
}
|
||||
result += below1000(n);
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
const dollarWords = dollars === 0 ? 'Zero' : toWords(dollars);
|
||||
const centStr = cents.toString().padStart(2, '0');
|
||||
return `${dollarWords} and ${centStr}/100`;
|
||||
}
|
||||
|
||||
// Format amount with ** padding (like the original software)
|
||||
function formatAmountDisplay(amount) {
|
||||
return `**${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Format MICR line
|
||||
// Standard check layout: [spaces][check#][transit symbol][routing][transit symbol][account][on-us symbol]
|
||||
// Using micrenc.ttf character mappings: A=transit, B=amount, C=on-us, D=dash
|
||||
// GnuMICR uses: 'A' for transit, 'C' for on-us
|
||||
function formatMicrLine(routingNo, accountNo, checkNo) {
|
||||
// Pad check number to 4+ digits
|
||||
const checkPadded = checkNo.toString().padStart(4, '0');
|
||||
// Routing: 9 digits, wrapped in transit symbols (A in micrenc)
|
||||
const routing = routingNo.replace(/\D/g, '');
|
||||
// Account: strip non-numeric, wrap in on-us symbols (C in micrenc)
|
||||
const account = accountNo.replace(/[^0-9]/g, '');
|
||||
|
||||
// MICR format: A[routing]A [account]C [check#]A
|
||||
// This is the standard US check layout
|
||||
return `A${routing}A ${account}C ${checkPadded}A`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main export: generates a PDF buffer for 1–3 checks.
|
||||
*
|
||||
* @param {Object} account - Account row from database
|
||||
* @param {Array} checks - Array of 1–3 check rows from database
|
||||
* @param {Array} fields - Layout field rows from layout_fields table
|
||||
* @returns {Promise<Buffer>} PDF as a buffer
|
||||
*/
|
||||
function generateCheckPdf(account, checks, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hasMicrFont = fs.existsSync(MICR_FONT_PATH);
|
||||
if (!hasMicrFont) {
|
||||
console.warn(`MICR font not found at ${MICR_FONT_PATH}. MICR line will use fallback font.`);
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: [
|
||||
PAGE_WIDTH_IN * POINTS_PER_INCH,
|
||||
PAGE_HEIGHT_IN * POINTS_PER_INCH,
|
||||
],
|
||||
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
autoFirstPage: true,
|
||||
});
|
||||
|
||||
if (hasMicrFont) {
|
||||
doc.registerFont('MICR', MICR_FONT_PATH);
|
||||
}
|
||||
|
||||
const buffers = [];
|
||||
doc.on('data', chunk => buffers.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(buffers)));
|
||||
doc.on('error', reject);
|
||||
|
||||
// Separate layout fields into check body vs stub fields
|
||||
const bodyFields = fields.filter(f => !f.field_name.startsWith('Stub'));
|
||||
const stubFields = fields.filter(f => f.field_name.startsWith('Stub'));
|
||||
|
||||
// We always render 3 slots; empty slots get a blank placeholder
|
||||
for (let slot = 0; slot < 3; slot++) {
|
||||
const check = checks[slot] || null;
|
||||
const slotOriginY = slot * SLOT_HEIGHT_IN;
|
||||
|
||||
// Offset adjustments from account calibration
|
||||
const offX = (account.offset_right - account.offset_left);
|
||||
const offY = (account.offset_down - account.offset_up);
|
||||
|
||||
// Helper: convert inches (relative to slot) to PDF points (absolute page)
|
||||
const pt = (xIn, yIn) => ({
|
||||
x: (xIn + offX) * POINTS_PER_INCH,
|
||||
y: (slotOriginY + yIn + offY) * POINTS_PER_INCH,
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
// Draw a faint slot boundary line for empty slots (optional, useful for alignment)
|
||||
doc.moveTo(0, slotOriginY * POINTS_PER_INCH)
|
||||
.lineTo(PAGE_WIDTH_IN * POINTS_PER_INCH, slotOriginY * POINTS_PER_INCH)
|
||||
.stroke('#cccccc');
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Render each layout field ---
|
||||
for (const field of bodyFields) {
|
||||
if (!field.visible) continue;
|
||||
|
||||
const pos = pt(field.x_pos, field.y_pos);
|
||||
|
||||
switch (field.field_type) {
|
||||
case 'Line': {
|
||||
const endPos = pt(field.x_end_pos, field.y_end_pos);
|
||||
doc.moveTo(pos.x, pos.y)
|
||||
.lineTo(endPos.x, endPos.y)
|
||||
.lineWidth(field.line_thick || 1)
|
||||
.stroke('#000000');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Graph': {
|
||||
// Logo or signature image
|
||||
const imgData = field.field_name === 'Logo'
|
||||
? account.logo_data
|
||||
: account.signature_data;
|
||||
|
||||
if (imgData) {
|
||||
try {
|
||||
// Data URI: strip the header, get base64
|
||||
const base64 = imgData.replace(/^data:[^;]+;base64,/, '');
|
||||
const imgBuffer = Buffer.from(base64, 'base64');
|
||||
const endPos = pt(field.x_end_pos, field.y_end_pos);
|
||||
const w = Math.abs(endPos.x - pos.x);
|
||||
const h = Math.abs(endPos.y - pos.y);
|
||||
doc.image(imgBuffer, pos.x, pos.y, { width: w, height: h });
|
||||
} catch (err) {
|
||||
console.warn(`Could not render image for field ${field.field_name}:`, err.message);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Text': {
|
||||
// Static label
|
||||
const label = field.field_text || '';
|
||||
setFont(doc, field);
|
||||
doc.fillColor('#000000')
|
||||
.text(label, pos.x, pos.y, { lineBreak: false });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Regular': {
|
||||
// Dynamic data - map field name to check/account data
|
||||
const value = resolveFieldValue(field.field_name, check, account);
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
setFont(doc, field);
|
||||
doc.fillColor('#000000')
|
||||
.text(String(value), pos.x, pos.y, { lineBreak: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MICR line ---
|
||||
const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no);
|
||||
const micrPos = pt(0.3, MICR_Y_IN);
|
||||
|
||||
if (hasMicrFont) {
|
||||
doc.font('MICR').fontSize(12).fillColor('#000000')
|
||||
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
|
||||
} else {
|
||||
// Fallback: Courier approximation (will not scan, but useful for dev)
|
||||
doc.font('Courier').fontSize(10).fillColor('#000000')
|
||||
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
|
||||
}
|
||||
|
||||
// --- Slot separator line ---
|
||||
if (slot < 2) {
|
||||
const lineY = (slotOriginY + SLOT_HEIGHT_IN) * POINTS_PER_INCH;
|
||||
doc.moveTo(0, lineY)
|
||||
.lineTo(PAGE_WIDTH_IN * POINTS_PER_INCH, lineY)
|
||||
.lineWidth(0.5)
|
||||
.stroke('#999999');
|
||||
}
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a layout field name to its runtime value from check/account data.
|
||||
* Field names come from T200's FldName column.
|
||||
*/
|
||||
function resolveFieldValue(fieldName, check, account) {
|
||||
switch (fieldName) {
|
||||
case 'Payee Name':
|
||||
return check.payee;
|
||||
case 'Amount':
|
||||
return formatAmountDisplay(check.amount);
|
||||
case 'Text Amount':
|
||||
return amountToWords(check.amount) + '***';
|
||||
case 'Date':
|
||||
return check.check_date;
|
||||
case 'Memo':
|
||||
return check.memo;
|
||||
case 'Check Number':
|
||||
return check.check_no;
|
||||
case 'Payee Address':
|
||||
// Multi-line address
|
||||
return [
|
||||
check.payee_address1,
|
||||
check.payee_address2,
|
||||
check.payee_address3,
|
||||
check.payee_address4,
|
||||
].filter(Boolean).join('\n');
|
||||
case 'Company Name':
|
||||
return account.company1;
|
||||
case 'Company Name2':
|
||||
return account.company2;
|
||||
case 'Bank Information':
|
||||
return [account.bank_info1, account.bank_info2, account.bank_info3]
|
||||
.filter(Boolean).join('\n');
|
||||
case 'Bank Transit Code':
|
||||
return account.transit_code;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PDFKit font based on a layout field's font properties.
|
||||
* Falls back to Helvetica if the stored font name is not a built-in.
|
||||
*/
|
||||
function setFont(doc, field) {
|
||||
const builtins = [
|
||||
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
|
||||
'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
|
||||
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
|
||||
];
|
||||
const fontName = builtins.includes(field.font_name) ? field.font_name : 'Helvetica';
|
||||
doc.font(fontName).fontSize(field.font_size || 10);
|
||||
}
|
||||
|
||||
module.exports = { generateCheckPdf, amountToWords, formatMicrLine };
|
||||
Reference in New Issue
Block a user