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:
2026-03-12 10:29:36 -06:00
parent 9fcb31ba0d
commit e252ddb952
35 changed files with 4112 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
MICR_FONT_PATH=/app/fonts/micrenc.ttf
+53
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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.
+86 -1
View File
@@ -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 13 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.
+65
View File
@@ -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
+22
View File
@@ -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:
+18
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
GnuMICR was created by:
Eric Sandeen <sandeen-gnumicr@sandeen.net>
+26
View File
@@ -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
View File
@@ -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.
+36
View File
@@ -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
+215
View File
@@ -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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1189
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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!
+3
View File
@@ -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
View File
@@ -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
+93
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
%!
/GnuMICR findfont
12 scalefont
setfont
newpath
100 500 moveto
(0123456789 A B C D) show
stroke
showpage
+374
View File
@@ -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);
}
+22
View File
@@ -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"
}
}
+313
View File
@@ -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; }
+122
View File
@@ -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>
+391
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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
View File
@@ -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;
+26
View File
@@ -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;
+82
View File
@@ -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);
+139
View File
@@ -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;
+62
View File
@@ -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 13 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 13 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;
+292
View File
@@ -0,0 +1,292 @@
'use strict';
/**
* pdfService.js
*
* Generates a 3-up check PDF from 13 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 13 checks.
*
* @param {Object} account - Account row from database
* @param {Array} checks - Array of 13 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 };