diff --git a/.do/deploy.template.yaml b/.do/deploy.template.yaml
new file mode 100644
index 0000000..311cd1e
--- /dev/null
+++ b/.do/deploy.template.yaml
@@ -0,0 +1,43 @@
+spec:
+ name: budgetzen
+ envs:
+ - key: BASE_URL
+ scope: RUN_AND_BUILD_TIME
+ value: ${app.PUBLIC_URL}
+ services:
+ - name: app
+ dockerfile_path: Dockerfile
+ git:
+ branch: main
+ http_port: 8000
+ instance_count: 1
+ instance_size_slug: basic-xs
+ routes:
+ - path: /
+ health_check:
+ http_path: /
+ source_dir: /
+ envs:
+ - key: POSTGRESQL_HOST
+ scope: RUN_AND_BUILD_TIME
+ value: ${db.HOSTNAME}
+ - key: POSTGRESQL_USER
+ scope: RUN_AND_BUILD_TIME
+ value: ${db.USERNAME}
+ - key: POSTGRESQL_PASSWORD
+ scope: RUN_AND_BUILD_TIME
+ value: ${db.PASSWORD}
+ - key: POSTGRESQL_DBNAME
+ scope: RUN_AND_BUILD_TIME
+ value: ${db.DATABASE}
+ - key: POSTGRESQL_PORT
+ scope: RUN_AND_BUILD_TIME
+ value: ${db.PORT}
+ - key: POSTGRESQL_CAFILE
+ scope: RUN_AND_BUILD_TIME
+ value: ""
+ databases:
+ - name: db
+ engine: PG
+ production: false
+ version: "15"
diff --git a/.dvmrc b/.dvmrc
index 83cf0d9..b0f3390 100644
--- a/.dvmrc
+++ b/.dvmrc
@@ -1 +1 @@
-1.29.1
+1.30.3
diff --git a/.env.sample b/.env.sample
index 8526897..55c5e57 100644
--- a/.env.sample
+++ b/.env.sample
@@ -1 +1,16 @@
-USERBASE_APP_ID=get-from-userbase.com
+PORT=8000
+BASE_URL="http://localhost:8000"
+
+POSTGRESQL_HOST="localhost"
+POSTGRESQL_USER="postgres"
+POSTGRESQL_PASSWORD="fake"
+POSTGRESQL_DBNAME="budgetzen"
+POSTGRESQL_PORT=5432
+POSTGRESQL_CAFILE=""
+
+POSTMARK_SERVER_API_TOKEN="fake"
+
+STRIPE_API_KEY="fake"
+
+PAYPAL_CLIENT_ID="fake"
+PAYPAL_CLIENT_SECRET="fake"
diff --git a/.github/workflows/cron-check-subscriptions.yml b/.github/workflows/cron-check-subscriptions.yml
new file mode 100644
index 0000000..8d5b0be
--- /dev/null
+++ b/.github/workflows/cron-check-subscriptions.yml
@@ -0,0 +1,28 @@
+name: "Cron: Check subscriptions"
+
+on:
+ workflow_dispatch:
+ schedule:
+ # At 04:06 every day.
+ - cron: '6 4 * * *'
+
+jobs:
+ cron-cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: denoland/setup-deno@v1
+ with:
+ deno-version: v1.30.3
+ - env:
+ POSTGRESQL_HOST: ${{ secrets.POSTGRESQL_HOST }}
+ POSTGRESQL_USER: ${{ secrets.POSTGRESQL_USER }}
+ POSTGRESQL_PASSWORD: ${{ secrets.POSTGRESQL_PASSWORD }}
+ POSTGRESQL_DBNAME: ${{ secrets.POSTGRESQL_DBNAME }}
+ POSTGRESQL_PORT: ${{ secrets.POSTGRESQL_PORT }}
+ POSTGRESQL_CAFILE: ${{ secrets.POSTGRESQL_CAFILE }}
+ STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
+ PAYPAL_CLIENT_ID: ${{ secrets.PAYPAL_CLIENT_ID }}
+ PAYPAL_CLIENT_SECRET: ${{ secrets.PAYPAL_CLIENT_SECRET }}
+ run: |
+ make crons/check-subscriptions
diff --git a/.github/workflows/cron-cleanup.yml b/.github/workflows/cron-cleanup.yml
new file mode 100644
index 0000000..08dfe14
--- /dev/null
+++ b/.github/workflows/cron-cleanup.yml
@@ -0,0 +1,25 @@
+name: "Cron: Cleanup"
+
+on:
+ workflow_dispatch:
+ schedule:
+ # At 03:05 every day.
+ - cron: '5 3 * * *'
+
+jobs:
+ cron-cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: denoland/setup-deno@v1
+ with:
+ deno-version: v1.30.3
+ - env:
+ POSTGRESQL_HOST: ${{ secrets.POSTGRESQL_HOST }}
+ POSTGRESQL_USER: ${{ secrets.POSTGRESQL_USER }}
+ POSTGRESQL_PASSWORD: ${{ secrets.POSTGRESQL_PASSWORD }}
+ POSTGRESQL_DBNAME: ${{ secrets.POSTGRESQL_DBNAME }}
+ POSTGRESQL_PORT: ${{ secrets.POSTGRESQL_PORT }}
+ POSTGRESQL_CAFILE: ${{ secrets.POSTGRESQL_CAFILE }}
+ run: |
+ make crons/cleanup
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 37ea88e..85b996a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,6 +9,12 @@ jobs:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
- deno-version: v1.29.1
+ deno-version: v1.30.3
+ - run: docker-compose pull
+ - uses: jpribyl/action-docker-layer-caching@v0.1.1
+ continue-on-error: true
- run: |
+ cp .env.sample .env
+ docker-compose up -d
+ make migrate-db
make test
diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..e1827d5
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,3 @@
+localhost
+
+reverse_proxy * localhost:8000
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1ea7be4
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM denoland/deno:1.30.3
+
+EXPOSE 8000
+
+WORKDIR /app
+
+# Prefer not to run as root.
+USER deno
+
+# These steps will be re-run upon each file change in your working directory:
+ADD . /app
+
+# Compile the main app so that it doesn't need to be compiled each startup/entry.
+RUN deno cache --reload main.ts
+
+CMD ["make", "start"]
diff --git a/Makefile b/Makefile
index db70e4d..ac5b86a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
.PHONY: start
start:
- deno run --watch --allow-net --allow-read=public,pages,.env,.env.defaults,.env.example --allow-env main.ts
+ deno run --watch --allow-net --allow-read --allow-env main.ts
.PHONY: format
format:
@@ -10,4 +10,20 @@ format:
test:
deno fmt --check
deno lint
- deno test --allow-net --allow-read=public,pages,.env,.env.defaults,.env.example --allow-env --check=all
+ deno test --allow-net --allow-read --allow-env --check
+
+.PHONY: migrate-db
+migrate-db:
+ deno run --allow-net --allow-read --allow-env migrate-db.ts
+
+.PHONY: crons/check-subscriptions
+crons/check-subscriptions:
+ deno run --allow-net --allow-read --allow-env crons/check-subscriptions.ts
+
+.PHONY: crons/cleanup
+crons/cleanup:
+ deno run --allow-net --allow-read --allow-env crons/cleanup.ts
+
+.PHONY: exec-db
+exec-db:
+ docker exec -it -u postgres $(shell basename $(CURDIR))_postgresql_1 psql
diff --git a/README.md b/README.md
index 1be7442..6c43f40 100644
--- a/README.md
+++ b/README.md
@@ -4,31 +4,61 @@
This is the web app for the [Budget Zen app](https://budgetzen.net), built with [Deno](https://deno.land) and deployed to [Deno Deploy](https://deno.com/deploy).
-This is v2, which is [end-to-end encrypted via userbase](https://userbase.com), and works via web on any device (it's a PWA - Progressive Web App).
+This is v3, which is [end-to-end encrypted with open Web Standards](https://en.wikipedia.org/wiki/End-to-end_encryption), and works via web on any device (it's a PWA - Progressive Web App).
-It's not compatible with Budget Zen v1 (not end-to-end encrypted), which you can still get locally from [this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/397d625469b7dfd8d1968c847b32e607ee7c8ee9). You can still export and import the data as the JSON format is the same (unencrypted).
+It's not compatible with Budget Zen v2 ([end-to-end encrypted via Userbase](https://userbase.com)) which you can still get locally from [this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/7e88a602be437cd4d54268f87113b21e9cff5c60), nor v1 (not end-to-end encrypted), which you can still get locally from [this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/397d625469b7dfd8d1968c847b32e607ee7c8ee9). You can still export and import the data as the JSON format is the same across all 3 versions (unencrypted).
+
+## Self-host it!
+
+[![Deploy to DigitalOcean](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/BrunoBernardino/budgetzen-web)
+
+[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/BrunoBernardino/budgetzen-web)
+
+Or check the [Development section below](#development).
+
+> **NOTE:** You don't need to have emails (Postmark) and subscriptions (Stripe/PayPal) setup to have the app work. Those are only used for allowing others to automatically manage their account. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly.
+
+## Framework-less
+
+This right here is vanilla TypeScript and JavaScript using Web Standards. It's very easy to update and maintain.
+
+It's meant to have no unnecessary dependencies, packagers, or bundlers. Just vanilla, simple stuff.
## Requirements
-This was tested with `deno`'s version in the `.dvmrc` file, though it's possible other versions might work.
+This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work.
-There are no other dependencies. **Deno**!
+For the PostgreSQL dependency (used when running locally, self-hosted, or in CI), you should have `Docker` and `docker-compose` installed.
+
+If you want to run the app locally with SSL (Web Crypto standards require `https` except for Chrome), you can use [`Caddy`](https://caddyserver.com) (there's a `Caddyfile` that proxies `https://localhost` to the Deno app).
+
+Don't forget to set up your `.env` file based on `.env.sample`.
## Development
```sh
-$ make start
-$ make format
-$ make test
+$ docker-compose up # (optional) runs docker with postgres, locally
+$ sudo caddy run # (optional) runs an https proxy for the deno app
+$ make migrate-db # runs any missing database migrations
+$ make start # runs the app
+$ make format # formats the code
+$ make test # runs tests
```
-## Structure
+## Other less-used commands
-This is vanilla JS, web standards, no frameworks. If you'd like to see/use [the Next.js version deployed to AWS via Serverless, check this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/b1097c710ba89abf9aed044a7d7444e91d04a6a7).
+```sh
+$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "budgetzen"; CREATE DATABASE "budgetzen";`
+```
+
+## Structure
- Backend routes are defined at `routes.ts`.
-- Static files are defined at `public/`.
+- Publicly-available files are defined at `public/`.
- Pages are defined at `pages/`.
+- Cron jobs are defined at `crons/`.
+- Reusable bits of code are defined at `lib/`.
+- Database migrations are defined at `db-migrations/`.
## Deployment
@@ -37,4 +67,3 @@ This is vanilla JS, web standards, no frameworks. If you'd like to see/use [the
## TODOs:
- [ ] Enable true offline mode (securely cache data, allow read-only)
- - https://github.com/smallbets/userbase/issues/255 has interesting ideas, while it's not natively supported
diff --git a/components/footer.ts b/components/footer.ts
index 56d3f77..ad68b56 100644
--- a/components/footer.ts
+++ b/components/footer.ts
@@ -1,4 +1,4 @@
-import { html } from '../lib/utils.ts';
+import { helpEmail, html } from '/lib/utils.ts';
export default function footer() {
return html`
@@ -9,7 +9,7 @@ export default function footer() {
What is Budget Zen?
- Simple and encrypted budget management.
+ Simple and encrypted expense management.
Read more here .
@@ -47,8 +47,9 @@ export default function footer() {
diff --git a/components/header.ts b/components/header.ts
index f986c5b..35584fa 100644
--- a/components/header.ts
+++ b/components/header.ts
@@ -1,4 +1,4 @@
-import { html } from '../lib/utils.ts';
+import { html } from '/lib/utils.ts';
export default function header(currentPath: string) {
return html`
diff --git a/components/loading.ts b/components/loading.ts
index 49b59f7..a3486a2 100644
--- a/components/loading.ts
+++ b/components/loading.ts
@@ -1,4 +1,4 @@
-import { html } from '../lib/utils.ts';
+import { html } from '/lib/utils.ts';
export default function loading() {
return html`
diff --git a/components/modals/verification-code.ts b/components/modals/verification-code.ts
new file mode 100644
index 0000000..25e0b9a
--- /dev/null
+++ b/components/modals/verification-code.ts
@@ -0,0 +1,31 @@
+import { html } from '/lib/utils.ts';
+
+export default function verificationCodeModal() {
+ return html`
+
+
+ Verification Code
+
+
+
+ You have received an email with a verification code. Type it here.
+
+
+
+
+ Verify
+
+
+ Cancel
+
+
+ `;
+}
diff --git a/crons/check-subscriptions.ts b/crons/check-subscriptions.ts
new file mode 100644
index 0000000..e89110a
--- /dev/null
+++ b/crons/check-subscriptions.ts
@@ -0,0 +1,88 @@
+import Database, { sql } from '/lib/interfaces/database.ts';
+import { getSubscriptions as getStripeSubscriptions } from '/lib/providers/stripe.ts';
+// import { getSubscriptions as getPaypalSubscriptions } from '/lib/providers/paypal.ts';
+import { updateUser } from '/lib/data-utils.ts';
+import { User } from '/lib/types.ts';
+
+const db = new Database();
+
+async function checkSubscriptions() {
+ try {
+ const users = await db.query(
+ sql`SELECT * FROM "budgetzen_users" WHERE "status" IN ('active', 'trial')`,
+ );
+
+ let updatedUsers = 0;
+
+ const stripeSubscriptions = await getStripeSubscriptions();
+
+ for (const subscription of stripeSubscriptions) {
+ // Skip subscriptions that aren't related to Budget Zen
+ if (!subscription.items.data.some((item) => item.price.id.startsWith('budget-zen-'))) {
+ continue;
+ }
+
+ const matchingUser = users.find((user) => user.email === subscription.customer.email);
+
+ if (matchingUser) {
+ if (!matchingUser.subscription.external.stripe) {
+ matchingUser.subscription.external.stripe = {
+ user_id: subscription.customer.id,
+ subscription_id: subscription.id,
+ };
+ }
+
+ matchingUser.subscription.isMonthly = subscription.items.data.some((item) => item.price.id.includes('monthly'));
+ matchingUser.subscription.updated_at = new Date().toISOString();
+ matchingUser.subscription.expires_at = new Date(subscription.current_period_end * 1000).toISOString();
+
+ if (['active', 'paused'].includes(subscription.status)) {
+ matchingUser.status = 'active';
+ } else if (subscription.status === 'trialing') {
+ matchingUser.status = 'trial';
+ } else {
+ matchingUser.status = 'inactive';
+ }
+
+ await updateUser(matchingUser);
+
+ ++updatedUsers;
+ }
+ }
+
+ // const paypalSubscriptions = await getPaypalSubscriptions();
+
+ // for (const subscription of paypalSubscriptions) {
+ // const matchingUser = users.find((user) => user.email === subscription.subscriber.email_address);
+
+ // if (matchingUser) {
+ // if (!matchingUser.subscription.external.paypal) {
+ // matchingUser.subscription.external.paypal = {
+ // user_id: subscription.subscriber.payer_id,
+ // subscription_id: subscription.id,
+ // };
+ // }
+
+ // matchingUser.subscription.isMonthly = parseInt(subscription.billing_info.last_payment.amount.value, 10) < 10;
+ // matchingUser.subscription.updated_at = new Date().toISOString();
+ // matchingUser.subscription.expires_at = new Date(subscription.billing_info.next_billing_time).toISOString();
+
+ // if (['ACTIVE', 'APPROVED'].includes(subscription.status)) {
+ // matchingUser.status = 'active';
+ // } else {
+ // matchingUser.status = 'inactive';
+ // }
+
+ // await updateUser(matchingUser);
+
+ // ++updatedUsers;
+ // }
+ // }
+
+ console.log('Updated', updatedUsers, 'users');
+ } catch (error) {
+ console.log(error);
+ }
+}
+
+await checkSubscriptions();
diff --git a/crons/cleanup.ts b/crons/cleanup.ts
new file mode 100644
index 0000000..1e07ad0
--- /dev/null
+++ b/crons/cleanup.ts
@@ -0,0 +1,82 @@
+import Database, { sql } from '/lib/interfaces/database.ts';
+import { User } from '/lib/types.ts';
+
+const db = new Database();
+
+async function cleanupSessions() {
+ const yesterday = new Date(new Date().setUTCDate(new Date().getUTCDate() - 1));
+
+ try {
+ const result = await db.query<{ count: number }>(
+ sql`WITH "deleted" AS (
+ DELETE FROM "budgetzen_user_sessions" WHERE "expires_at" <= $1 RETURNING *
+ )
+ SELECT COUNT(*) FROM "deleted"`,
+ [
+ yesterday.toISOString().substring(0, 10),
+ ],
+ );
+
+ console.log('Deleted', result[0].count, 'user sessions');
+ } catch (error) {
+ console.log(error);
+ }
+}
+
+async function cleanupInactiveUsers() {
+ const thirtyDaysAgo = new Date(new Date().setUTCDate(new Date().getUTCDate() - 30));
+
+ try {
+ const result = await db.query>(
+ sql`SELECT "id" FROM "budgetzen_users" WHERE "status" = 'inactive' AND "subscription" ->> 'expires_at' <= $1`,
+ [
+ thirtyDaysAgo.toISOString().substring(0, 10),
+ ],
+ );
+
+ const userIdsToDelete = result.map((user) => user.id);
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_user_sessions" WHERE "user_id" = ANY($1)`,
+ [
+ userIdsToDelete,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_verification_codes" WHERE "user_id" = ANY($1)`,
+ [
+ userIdsToDelete,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_budgets" WHERE "user_id" = ANY($1)`,
+ [
+ userIdsToDelete,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_expenses" WHERE "user_id" = ANY($1)`,
+ [
+ userIdsToDelete,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_users" WHERE "id" = ANY($1)`,
+ [
+ userIdsToDelete,
+ ],
+ );
+
+ console.log('Deleted', userIdsToDelete.length, 'users');
+ } catch (error) {
+ console.log(error);
+ }
+}
+
+await cleanupInactiveUsers();
+
+await cleanupSessions();
diff --git a/db-migrations/001-base.pgsql b/db-migrations/001-base.pgsql
new file mode 100644
index 0000000..64852c9
--- /dev/null
+++ b/db-migrations/001-base.pgsql
@@ -0,0 +1,239 @@
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+
+--
+-- Name: budgetzen_user_sessions; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_user_sessions (
+ id uuid DEFAULT gen_random_uuid(),
+ user_id uuid DEFAULT gen_random_uuid(),
+ expires_at timestamp with time zone NOT NULL,
+ verified BOOLEAN NOT NULL,
+ last_seen_at timestamp with time zone DEFAULT now(),
+ created_at timestamp with time zone DEFAULT now()
+);
+
+
+ALTER TABLE public.budgetzen_user_sessions OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_verification_codes; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_verification_codes (
+ id uuid DEFAULT gen_random_uuid(),
+ user_id uuid DEFAULT gen_random_uuid(),
+ code character varying NOT NULL,
+ verification jsonb NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ created_at timestamp with time zone DEFAULT now()
+);
+
+
+ALTER TABLE public.budgetzen_verification_codes OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_budgets; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_budgets (
+ id uuid DEFAULT gen_random_uuid(),
+ user_id uuid DEFAULT gen_random_uuid(),
+ name text NOT NULL,
+ month character varying NOT NULL,
+ value text NOT NULL,
+ extra jsonb NOT NULL
+);
+
+
+ALTER TABLE public.budgetzen_budgets OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_expenses; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_expenses (
+ id uuid DEFAULT gen_random_uuid(),
+ user_id uuid DEFAULT gen_random_uuid(),
+ cost text NOT NULL,
+ description text NOT NULL,
+ budget text NOT NULL,
+ date character varying NOT NULL,
+ is_recurring BOOLEAN NOT NULL,
+ extra jsonb NOT NULL
+);
+
+
+ALTER TABLE public.budgetzen_expenses OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_users; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_users (
+ id uuid DEFAULT gen_random_uuid(),
+ email character varying NOT NULL,
+ encrypted_key_pair text NOT NULL,
+ subscription jsonb NOT NULL,
+ status character varying NOT NULL,
+ extra jsonb NOT NULL,
+ created_at timestamp with time zone DEFAULT now()
+);
+
+
+ALTER TABLE public.budgetzen_users OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_migrations; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.budgetzen_migrations (
+ id uuid DEFAULT gen_random_uuid(),
+ name character varying(100) NOT NULL,
+ executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+ALTER TABLE public.budgetzen_migrations OWNER TO postgres;
+
+
+--
+-- Name: budgetzen_user_sessions budgetzen_user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_user_sessions
+ ADD CONSTRAINT budgetzen_user_sessions_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: budgetzen_verification_codes budgetzen_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_verification_codes
+ ADD CONSTRAINT budgetzen_verification_codes_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: budgetzen_budgets budgetzen_budgets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_budgets
+ ADD CONSTRAINT budgetzen_budgets_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: budgetzen_expenses budgetzen_expenses_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_expenses
+ ADD CONSTRAINT budgetzen_expenses_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: budgetzen_users budgetzen_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_users
+ ADD CONSTRAINT budgetzen_users_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: budgetzen_user_sessions budgetzen_user_sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_user_sessions
+ ADD CONSTRAINT budgetzen_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.budgetzen_users(id);
+
+
+--
+-- Name: budgetzen_verification_codes budgetzen_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_verification_codes
+ ADD CONSTRAINT budgetzen_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.budgetzen_users(id);
+
+
+--
+-- Name: budgetzen_budgets budgetzen_budgets_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_budgets
+ ADD CONSTRAINT budgetzen_budgets_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.budgetzen_users(id);
+
+
+--
+-- Name: budgetzen_expenses budgetzen_expenses_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.budgetzen_expenses
+ ADD CONSTRAINT budgetzen_expenses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.budgetzen_users(id);
+
+
+--
+-- Name: TABLE budgetzen_user_sessions; Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON TABLE public.budgetzen_user_sessions TO postgres;
+
+
+--
+-- Name: TABLE budgetzen_verification_codes; Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON TABLE public.budgetzen_verification_codes TO postgres;
+
+
+--
+-- Name: TABLE budgetzen_budgets; Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON TABLE public.budgetzen_budgets TO postgres;
+
+
+--
+-- Name: TABLE budgetzen_expenses; Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON TABLE public.budgetzen_expenses TO postgres;
+
+
+--
+-- Name: TABLE budgetzen_users; Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON TABLE public.budgetzen_users TO postgres;
+
+
+--
+-- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres
+--
+
+ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres;
+
+
+--
+-- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres
+--
+
+ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres;
+
+
+--
+-- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres
+--
+
+ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres;
diff --git a/deno.json b/deno.json
index 56a759d..a244982 100644
--- a/deno.json
+++ b/deno.json
@@ -9,10 +9,7 @@
},
"files": {
"exclude": [
- "public/js/stripe.js",
- "public/js/sweetalert.js",
- "public/js/userbase.js",
- "public/js/userbase.js.map"
+ "public/js/sweetalert.js"
]
}
},
@@ -25,13 +22,18 @@
},
"files": {
"exclude": [
- "public/js/stripe.js",
- "public/js/sweetalert.js",
- "public/js/userbase.js",
- "public/js/userbase.js.map"
+ "public/js/sweetalert.js"
]
}
},
+ "compilerOptions": {
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "dom.asynciterable",
+ "deno.ns"
+ ]
+ },
"importMap": "./import_map.json",
"lock": false
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..38fd405
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,31 @@
+services:
+ postgresql:
+ image: postgres:15
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=fake
+ - POSTGRES_DB=budgetzen
+ restart: on-failure
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ ports:
+ - 5432:5432
+ ulimits:
+ memlock:
+ soft: -1
+ hard: -1
+
+ # NOTE: This would be nice to develop with https:// locally, but it doesn't work, for whatever reason, so we need a system caddy instead
+ # caddy:
+ # image: caddy:2-alpine
+ # restart: unless-stopped
+ # command: caddy reverse-proxy --from https://localhost:443 --to http://localhost:8000
+ # network_mode: "host"
+ # volumes:
+ # - caddy:/data
+
+volumes:
+ pgdata:
+ driver: local
+ # caddy:
+ # driver: local
diff --git a/lib/data-utils.ts b/lib/data-utils.ts
new file mode 100644
index 0000000..527d843
--- /dev/null
+++ b/lib/data-utils.ts
@@ -0,0 +1,478 @@
+import Database, { sql } from './interfaces/database.ts';
+import { Budget, Expense, User, UserSession, VerificationCode } from './types.ts';
+import { generateRandomCode, splitArrayInChunks } from './utils.ts';
+
+const db = new Database();
+
+export const monthRegExp = new RegExp(/^\d{4}\-\d{2}$/);
+
+export async function getUserByEmail(email: string) {
+ const lowercaseEmail = email.toLowerCase().trim();
+
+ const user = (await db.query(sql`SELECT * FROM "budgetzen_users" WHERE "email" = $1 LIMIT 1`, [
+ lowercaseEmail,
+ ]))[0];
+
+ return user;
+}
+
+export async function getUserById(id: string) {
+ const user = (await db.query(sql`SELECT * FROM "budgetzen_users" WHERE "id" = $1 LIMIT 1`, [
+ id,
+ ]))[0];
+
+ return user;
+}
+
+export async function createUser(email: User['email'], encryptedKeyPair: User['encrypted_key_pair']) {
+ const trialDays = 30;
+ const now = new Date();
+ const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays));
+
+ const subscription: User['subscription'] = {
+ external: {},
+ expires_at: trialEndDate.toISOString(),
+ updated_at: now.toISOString(),
+ };
+
+ const newUser = (await db.query(
+ sql`INSERT INTO "budgetzen_users" (
+ "email",
+ "subscription",
+ "status",
+ "encrypted_key_pair",
+ "extra"
+ ) VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [
+ email,
+ JSON.stringify(subscription),
+ 'trial',
+ encryptedKeyPair,
+ JSON.stringify({}),
+ ],
+ ))[0];
+
+ return newUser;
+}
+
+export async function updateUser(user: User) {
+ await db.query(
+ sql`UPDATE "budgetzen_users" SET
+ "email" = $2,
+ "subscription" = $3,
+ "status" = $4,
+ "encrypted_key_pair" = $5,
+ "extra" = $6
+ WHERE "id" = $1`,
+ [
+ user.id,
+ user.email,
+ JSON.stringify(user.subscription),
+ user.status,
+ user.encrypted_key_pair,
+ JSON.stringify(user.extra),
+ ],
+ );
+}
+
+export async function deleteUser(userId: string) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_user_sessions" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_verification_codes" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_budgets" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_expenses" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+
+ await db.query(
+ sql`DELETE FROM "budgetzen_users" WHERE "id" = $1`,
+ [
+ userId,
+ ],
+ );
+}
+
+export async function getSessionById(id: string) {
+ const session = (await db.query(
+ sql`SELECT * FROM "budgetzen_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`,
+ [
+ id,
+ ],
+ ))[0];
+
+ return session;
+}
+
+export async function createSession(user: User, isNewUser = false) {
+ // Add new user session to the db
+ const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
+
+ const newSession: Omit = {
+ user_id: user.id,
+ expires_at: oneMonthFromToday,
+ last_seen_at: new Date(),
+ verified: isNewUser,
+ };
+
+ const newUserSessionResult = (await db.query(
+ sql`INSERT INTO "budgetzen_user_sessions" (
+ "user_id",
+ "expires_at",
+ "verified",
+ "last_seen_at"
+ ) VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [
+ newSession.user_id,
+ newSession.expires_at,
+ newSession.verified,
+ newSession.last_seen_at,
+ ],
+ ))[0];
+
+ return newUserSessionResult;
+}
+
+export async function updateSession(session: UserSession) {
+ await db.query(
+ sql`UPDATE "budgetzen_user_sessions" SET
+ "expires_at" = $2,
+ "verified" = $3,
+ "last_seen_at" = $4
+ WHERE "id" = $1`,
+ [
+ session.id,
+ session.expires_at,
+ session.verified,
+ session.last_seen_at,
+ ],
+ );
+}
+
+export async function validateUserAndSession(userId: string, sessionId: string, acceptUnverifiedSession = false) {
+ const user = await getUserById(userId);
+
+ if (!user) {
+ throw new Error('Not Found');
+ }
+
+ const session = await getSessionById(sessionId);
+
+ if (!session || session.user_id !== user.id || (!session.verified && !acceptUnverifiedSession)) {
+ throw new Error('Not Found');
+ }
+
+ session.last_seen_at = new Date();
+
+ await updateSession(session);
+
+ return { user, session };
+}
+
+export async function createVerificationCode(
+ user: User,
+ session: UserSession,
+ type: VerificationCode['verification']['type'],
+) {
+ const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30));
+
+ const code = generateRandomCode();
+
+ const newVerificationCode: Omit = {
+ user_id: user.id,
+ code,
+ expires_at: inThirtyMinutes,
+ verification: {
+ id: session.id,
+ type,
+ },
+ };
+
+ await db.query(
+ sql`INSERT INTO "budgetzen_verification_codes" (
+ "user_id",
+ "code",
+ "expires_at",
+ "verification"
+ ) VALUES ($1, $2, $3, $4)
+ RETURNING "id"`,
+ [
+ newVerificationCode.user_id,
+ newVerificationCode.code,
+ newVerificationCode.expires_at,
+ JSON.stringify(newVerificationCode.verification),
+ ],
+ );
+
+ return code;
+}
+
+export async function validateVerificationCode(
+ user: User,
+ session: UserSession,
+ code: string,
+ type: VerificationCode['verification']['type'],
+) {
+ const verificationCode = (await db.query(
+ sql`SELECT * FROM "budgetzen_verification_codes"
+ WHERE "user_id" = $1 AND
+ "code" = $2 AND
+ "verification" ->> 'type' = $3 AND
+ "verification" ->> 'id' = $4 AND
+ "expires_at" > now()
+ LIMIT 1`,
+ [
+ user.id,
+ code,
+ type,
+ session.id,
+ ],
+ ))[0];
+
+ if (verificationCode) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_verification_codes" WHERE "id" = $1`,
+ [
+ verificationCode.id,
+ ],
+ );
+ } else {
+ throw new Error('Not Found');
+ }
+}
+
+export async function getAllBudgets(userId: string) {
+ const budgets = await db.query(
+ sql`SELECT * FROM "budgetzen_budgets"
+ WHERE "user_id" = $1
+ ORDER BY "month" DESC`,
+ [
+ userId,
+ ],
+ );
+
+ return budgets;
+}
+
+export async function getBudgetsByMonth(userId: string, month: string) {
+ const budgets = await db.query(
+ sql`SELECT * FROM "budgetzen_budgets"
+ WHERE "user_id" = $1 AND
+ "month" = $2
+ ORDER BY "month" DESC`,
+ [
+ userId,
+ month,
+ ],
+ );
+
+ return budgets;
+}
+
+export async function createBudget(budget: Omit) {
+ const newBudget = (await db.query(
+ sql`INSERT INTO "budgetzen_budgets" (
+ "user_id",
+ "name",
+ "month",
+ "value",
+ "extra"
+ ) VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [
+ budget.user_id,
+ budget.name,
+ budget.month,
+ budget.value,
+ JSON.stringify(budget.extra),
+ ],
+ ))[0];
+
+ return newBudget;
+}
+
+// Don't allow updating a budget's month
+export async function updateBudget(budget: Omit & { month?: Budget['month'] }) {
+ await db.query(
+ sql`UPDATE "budgetzen_budgets" SET
+ "name" = $2,
+ "value" = $3,
+ "extra" = $4
+ WHERE "id" = $1`,
+ [
+ budget.id,
+ budget.name,
+ budget.value,
+ JSON.stringify(budget.extra),
+ ],
+ );
+}
+
+export async function deleteBudget(budgetId: string) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_budgets" WHERE "id" = $1`,
+ [
+ budgetId,
+ ],
+ );
+}
+
+export async function deleteAllBudgets(userId: string) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_budgets" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+}
+
+export async function getAllExpenses(userId: string) {
+ const expenses = await db.query(
+ sql`SELECT * FROM "budgetzen_expenses"
+ WHERE "user_id" = $1
+ ORDER BY "date" DESC`,
+ [
+ userId,
+ ],
+ );
+
+ return expenses;
+}
+
+export async function getExpensesByMonth(userId: string, month: string) {
+ const expenses = await db.query(
+ sql`SELECT * FROM "budgetzen_expenses"
+ WHERE "user_id" = $1 AND
+ "date" >= '${month}-01' AND
+ "date" <= '${month}-31'
+ ORDER BY "date" DESC`,
+ [
+ userId,
+ ],
+ );
+
+ return expenses;
+}
+
+export async function createExpense(expense: Omit) {
+ const newExpense = (await db.query(
+ sql`INSERT INTO "budgetzen_expenses" (
+ "user_id",
+ "cost",
+ "description",
+ "budget",
+ "date",
+ "is_recurring",
+ "extra"
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING *`,
+ [
+ expense.user_id,
+ expense.cost,
+ expense.description,
+ expense.budget,
+ expense.date,
+ expense.is_recurring,
+ JSON.stringify(expense.extra),
+ ],
+ ))[0];
+
+ return newExpense;
+}
+
+export async function updateExpense(expense: Expense) {
+ await db.query(
+ sql`UPDATE "budgetzen_expenses" SET
+ "cost" = $2,
+ "description" = $3,
+ "budget" = $4,
+ "date" = $5,
+ "is_recurring" = $6,
+ "extra" = $7
+ WHERE "id" = $1`,
+ [
+ expense.id,
+ expense.cost,
+ expense.description,
+ expense.budget,
+ expense.date,
+ expense.is_recurring,
+ JSON.stringify(expense.extra),
+ ],
+ );
+}
+
+export async function deleteExpense(expenseId: string) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_expenses" WHERE "id" = $1`,
+ [
+ expenseId,
+ ],
+ );
+}
+
+export async function deleteAllExpenses(userId: string) {
+ await db.query(
+ sql`DELETE FROM "budgetzen_expenses" WHERE "user_id" = $1`,
+ [
+ userId,
+ ],
+ );
+}
+
+export async function importUserData(
+ userId: string,
+ budgets: Omit[],
+ expenses: Omit[],
+) {
+ const addBudgetChunks = splitArrayInChunks(
+ budgets,
+ 100, // import in transactions of 100 events each
+ );
+
+ for (const budgetsToAdd of addBudgetChunks) {
+ await db.query(sql`BEGIN;`);
+
+ for (const budget of budgetsToAdd) {
+ await createBudget({ ...budget, user_id: userId });
+ }
+
+ await db.query(sql`COMMIT;`);
+ }
+
+ const addExpenseChunks = splitArrayInChunks(
+ expenses,
+ 100, // import in transactions of 100 events each
+ );
+
+ for (const expensesToAdd of addExpenseChunks) {
+ await db.query(sql`BEGIN;`);
+
+ for (const expense of expensesToAdd) {
+ await createExpense({ ...expense, user_id: userId });
+ }
+
+ await db.query(sql`COMMIT;`);
+ }
+}
diff --git a/lib/interfaces/database.ts b/lib/interfaces/database.ts
new file mode 100644
index 0000000..30d969e
--- /dev/null
+++ b/lib/interfaces/database.ts
@@ -0,0 +1,76 @@
+import { Client } from 'https://deno.land/x/postgres@v0.17.0/mod.ts';
+import 'std/dotenv/load.ts';
+
+const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
+const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';
+const POSTGRESQL_PASSWORD = Deno.env.get('POSTGRESQL_PASSWORD') || '';
+const POSTGRESQL_DBNAME = Deno.env.get('POSTGRESQL_DBNAME') || '';
+const POSTGRESQL_PORT = Deno.env.get('POSTGRESQL_PORT') || '';
+const POSTGRESQL_CAFILE = Deno.env.get('POSTGRESQL_CAFILE') || '';
+
+const tls = POSTGRESQL_CAFILE
+ ? {
+ enabled: true,
+ enforce: false,
+ caCertificates: [await Deno.readTextFile(POSTGRESQL_CAFILE)],
+ }
+ : {
+ enabled: true,
+ enforce: false,
+ };
+
+export default class Database {
+ protected db?: Client;
+
+ constructor(connectNow = false) {
+ if (connectNow) {
+ this.connectToPostgres();
+ }
+ }
+
+ protected async connectToPostgres() {
+ if (this.db) {
+ return this.db;
+ }
+
+ const postgresClient = new Client({
+ user: POSTGRESQL_USER,
+ password: POSTGRESQL_PASSWORD,
+ database: POSTGRESQL_DBNAME,
+ hostname: POSTGRESQL_HOST,
+ port: POSTGRESQL_PORT,
+ tls,
+ });
+
+ await postgresClient.connect();
+
+ this.db = postgresClient;
+ }
+
+ protected async disconnectFromPostgres() {
+ if (!this.db) {
+ return;
+ }
+
+ await this.db.end();
+
+ this.db = undefined;
+ }
+
+ public close() {
+ this.disconnectFromPostgres();
+ }
+
+ public async query(sql: string, args?: any[]) {
+ if (!this.db) {
+ await this.connectToPostgres();
+ }
+
+ const result = await this.db!.queryObject(sql, args);
+
+ return result.rows;
+ }
+}
+
+// This allows us to have nice SQL syntax highlighting in template literals
+export const sql = String.raw;
diff --git a/lib/providers/paypal.ts b/lib/providers/paypal.ts
new file mode 100644
index 0000000..153f88c
--- /dev/null
+++ b/lib/providers/paypal.ts
@@ -0,0 +1,83 @@
+import 'std/dotenv/load.ts';
+
+const PAYPAL_CLIENT_ID = Deno.env.get('PAYPAL_CLIENT_ID') || '';
+const PAYPAL_CLIENT_SECRET = Deno.env.get('PAYPAL_CLIENT_SECRET') || '';
+
+interface PaypalSubscriber {
+ payer_id: string;
+ name: {
+ given_name: string;
+ surname: string;
+ };
+ email_address: string;
+}
+
+interface PaypalSubscription {
+ id: string;
+ plan_id: string;
+ start_time: string;
+ subscriber: PaypalSubscriber;
+ billing_info: {
+ next_billing_time: string;
+ last_payment: {
+ amount: {
+ value: string;
+ };
+ time: string;
+ };
+ };
+ create_time: string;
+ status: 'ACTIVE' | 'APPROVED' | 'APPROVAL_PENDING' | 'SUSPENDED' | 'CANCELLED' | 'EXPIRED';
+}
+
+let stripeAccessToken = '';
+
+async function getApiRequestHeaders() {
+ if (!stripeAccessToken) {
+ stripeAccessToken = await getAccessToken();
+ }
+
+ return {
+ 'Authorization': `Bearer ${stripeAccessToken}`,
+ 'Accept': 'application/json; charset=utf-8',
+ 'Content-Type': 'application/json; charset=utf-8',
+ };
+}
+
+async function getAccessToken() {
+ const body = { grant_type: 'client_credentials' };
+
+ const response = await fetch('https://api-m.paypal.com/v1/oauth2/token', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Basic ${btoa(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`)}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams(Object.entries(body)).toString(),
+ });
+
+ const result = (await response.json()) as { access_token: string };
+
+ return result.access_token;
+}
+
+export async function getSubscriptions() {
+ const searchParams = new URLSearchParams();
+
+ searchParams.set('page_size', '20');
+
+ // NOTE: This doesn't exist yet
+ const response = await fetch(`https://api-m.paypal.com/v1/billing/subscriptions?${searchParams.toString()}`, {
+ method: 'GET',
+ headers: await getApiRequestHeaders(),
+ });
+
+ const result = (await response.json()) as PaypalSubscription[];
+
+ if (!result) {
+ console.log(JSON.stringify({ result }, null, 2));
+ throw new Error(`Failed to make API request: "${result}"`);
+ }
+
+ return result;
+}
diff --git a/lib/providers/postmark.ts b/lib/providers/postmark.ts
new file mode 100644
index 0000000..01919d5
--- /dev/null
+++ b/lib/providers/postmark.ts
@@ -0,0 +1,153 @@
+import 'std/dotenv/load.ts';
+
+import { helpEmail } from '/lib/utils.ts';
+
+const POSTMARK_SERVER_API_TOKEN = Deno.env.get('POSTMARK_SERVER_API_TOKEN') || '';
+
+interface PostmarkResponse {
+ To: string;
+ SubmittedAt: string;
+ MessageID: string;
+ ErrorCode: number;
+ Message: string;
+}
+
+type TemplateAlias = 'verify-login' | 'verify-delete' | 'verify-update' | 'update-paddle-email';
+
+function getApiRequestHeaders() {
+ return {
+ 'X-Postmark-Server-Token': POSTMARK_SERVER_API_TOKEN,
+ 'Accept': 'application/json; charset=utf-8',
+ 'Content-Type': 'application/json; charset=utf-8',
+ };
+}
+
+interface PostmarkEmailWithTemplateRequestBody {
+ TemplateId?: number;
+ TemplateAlias: TemplateAlias;
+ TemplateModel: {
+ [key: string]: any;
+ };
+ InlineCss?: boolean;
+ From: string;
+ To: string;
+ Cc?: string;
+ Bcc?: string;
+ Tag?: string;
+ ReplyTo?: string;
+ Headers?: { Name: string; Value: string }[];
+ TrackOpens?: boolean;
+ TrackLinks?: 'None' | 'HtmlAndText' | 'HtmlOnly' | 'TextOnly';
+ Attachments?: { Name: string; Content: string; ContentType: string }[];
+ Metadata?: {
+ [key: string]: string;
+ };
+ MessageStream: 'outbound' | 'broadcast';
+}
+
+async function sendEmailWithTemplate(
+ to: string,
+ templateAlias: TemplateAlias,
+ data: PostmarkEmailWithTemplateRequestBody['TemplateModel'],
+ attachments: PostmarkEmailWithTemplateRequestBody['Attachments'] = [],
+ cc?: string,
+) {
+ const email: PostmarkEmailWithTemplateRequestBody = {
+ From: helpEmail,
+ To: to,
+ TemplateAlias: templateAlias,
+ TemplateModel: data,
+ MessageStream: 'outbound',
+ };
+
+ if (attachments?.length) {
+ email.Attachments = attachments;
+ }
+
+ if (cc) {
+ email.Cc = cc;
+ }
+
+ const postmarkResponse = await fetch('https://api.postmarkapp.com/email/withTemplate', {
+ method: 'POST',
+ headers: getApiRequestHeaders(),
+ body: JSON.stringify(email),
+ });
+ const postmarkResult = (await postmarkResponse.json()) as PostmarkResponse;
+
+ if (postmarkResult.ErrorCode !== 0 || postmarkResult.Message !== 'OK') {
+ console.log(JSON.stringify({ postmarkResult }, null, 2));
+ throw new Error(`Failed to send email "${templateAlias}"`);
+ }
+}
+
+export async function sendVerifyLoginEmail(
+ email: string,
+ verificationCode: string,
+) {
+ const data = {
+ verificationCode,
+ };
+
+ await sendEmailWithTemplate(email, 'verify-login', data);
+}
+
+export async function sendVerifyDeleteDataEmail(
+ email: string,
+ verificationCode: string,
+) {
+ const data = {
+ verificationCode,
+ deletionSubject: 'all your data',
+ };
+
+ await sendEmailWithTemplate(email, 'verify-delete', data);
+}
+
+export async function sendVerifyDeleteAccountEmail(
+ email: string,
+ verificationCode: string,
+) {
+ const data = {
+ verificationCode,
+ deletionSubject: 'your account',
+ };
+
+ await sendEmailWithTemplate(email, 'verify-delete', data);
+}
+
+export async function sendVerifyUpdateEmailEmail(
+ email: string,
+ verificationCode: string,
+) {
+ const data = {
+ verificationCode,
+ updateSubject: 'your email',
+ };
+
+ await sendEmailWithTemplate(email, 'verify-update', data);
+}
+
+export async function sendVerifyUpdatePasswordEmail(
+ email: string,
+ verificationCode: string,
+) {
+ const data = {
+ verificationCode,
+ updateSubject: 'your password',
+ };
+
+ await sendEmailWithTemplate(email, 'verify-update', data);
+}
+
+export async function sendUpdateEmailInProviderEmail(
+ oldEmail: string,
+ newEmail: string,
+) {
+ const data = {
+ oldEmail,
+ newEmail,
+ };
+
+ await sendEmailWithTemplate(helpEmail, 'update-paddle-email', data);
+}
diff --git a/lib/providers/stripe.ts b/lib/providers/stripe.ts
new file mode 100644
index 0000000..15abf96
--- /dev/null
+++ b/lib/providers/stripe.ts
@@ -0,0 +1,92 @@
+import 'std/dotenv/load.ts';
+
+const STRIPE_API_KEY = Deno.env.get('STRIPE_API_KEY') || '';
+
+interface StripeCustomer {
+ id: string;
+ object: 'customer';
+ balance: number;
+ created: number;
+ currency?: string | null;
+ deleted?: void;
+ delinquent?: boolean | null;
+ email: string | null;
+ name?: string | null;
+}
+
+interface StripeSubscription {
+ id: string;
+ object: 'subscription';
+ application: string | null;
+ cancel_at: number | null;
+ canceled_at: number | null;
+ created: number;
+ currency: string;
+ current_period_end: number;
+ current_period_start: number;
+ customer: StripeCustomer;
+ days_until_due: number | null;
+ ended_at: number | null;
+ items: {
+ object: 'list';
+ data: StripeSubscriptionItem[];
+ };
+ start_date: number;
+ status:
+ | 'active'
+ | 'canceled'
+ | 'incomplete'
+ | 'incomplete_expired'
+ | 'past_due'
+ | 'paused'
+ | 'trialing'
+ | 'unpaid';
+}
+
+interface StripeSubscriptionItem {
+ id: string;
+ object: 'subscription_item';
+ created: number;
+ deleted?: void;
+ price: {
+ id: string;
+ };
+}
+
+interface StripeResponse {
+ object: 'list';
+ url: string;
+ has_more: boolean;
+ data: any[];
+}
+
+function getApiRequestHeaders() {
+ return {
+ 'Authorization': `Bearer ${STRIPE_API_KEY}`,
+ 'Accept': 'application/json; charset=utf-8',
+ 'Content-Type': 'application/json; charset=utf-8',
+ };
+}
+
+export async function getSubscriptions() {
+ const searchParams = new URLSearchParams();
+
+ searchParams.set('expand[]', 'data.customer');
+ searchParams.set('limit', '100');
+
+ const response = await fetch(`https://api.stripe.com/v1/subscriptions?${searchParams.toString()}`, {
+ method: 'GET',
+ headers: getApiRequestHeaders(),
+ });
+
+ const result = (await response.json()) as StripeResponse;
+
+ const subscriptions = result.data as StripeSubscription[];
+
+ if (!subscriptions) {
+ console.log(JSON.stringify({ result }, null, 2));
+ throw new Error(`Failed to make API request: "${result}"`);
+ }
+
+ return subscriptions;
+}
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000..bd92189
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,75 @@
+import { SupportedCurrencySymbol } from '../public/ts/utils.ts';
+
+export type EncryptedData = string;
+
+export interface KeyPair {
+ publicKeyJwk: JsonWebKey;
+ privateKeyJwk: JsonWebKey;
+}
+
+export interface User {
+ id: string;
+ email: string;
+ encrypted_key_pair: EncryptedData;
+ subscription: {
+ external: {
+ paypal?: {
+ user_id: string;
+ subscription_id: string;
+ };
+ stripe?: {
+ user_id: string;
+ subscription_id: string;
+ };
+ };
+ isMonthly?: boolean;
+ expires_at: string;
+ updated_at: string;
+ };
+ status: 'trial' | 'active' | 'inactive';
+ extra: {
+ currency?: SupportedCurrencySymbol;
+ };
+ created_at: Date;
+}
+
+export interface UserSession {
+ id: string;
+ user_id: string;
+ expires_at: Date;
+ verified: boolean;
+ last_seen_at: Date;
+ created_at: Date;
+}
+
+export interface VerificationCode {
+ id: string;
+ user_id: string;
+ code: string;
+ verification: {
+ type: 'session' | 'user-update' | 'data-delete' | 'user-delete';
+ id: string;
+ };
+ expires_at: Date;
+ created_at: Date;
+}
+
+export interface Budget {
+ id: string;
+ user_id: User['id'];
+ name: EncryptedData;
+ month: string;
+ value: EncryptedData;
+ extra: Record; // NOTE: Here for potential future fields
+}
+
+export interface Expense {
+ id: string;
+ user_id: User['id'];
+ cost: EncryptedData;
+ description: EncryptedData;
+ budget: EncryptedData;
+ date: string;
+ is_recurring: boolean;
+ extra: Record; // NOTE: Here for potential future fields
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index 971ff95..2fb6e76 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,18 +1,33 @@
import 'std/dotenv/load.ts';
+import { emit } from 'https://deno.land/x/emit@0.15.0/mod.ts';
+import sass from 'https://deno.land/x/denosass@1.0.6/mod.ts';
+import { serveFile } from 'std/http/file_server.ts';
-import header from '../components/header.ts';
-import footer from '../components/footer.ts';
-import loading from '../components/loading.ts';
+import header from '/components/header.ts';
+import footer from '/components/footer.ts';
+import loading from '/components/loading.ts';
// This allows us to have nice html syntax highlighting in template literals
export const html = String.raw;
-const USERBASE_APP_ID = Deno.env.get('USERBASE_APP_ID') || '';
-const sessionLengthInHours = 90 * 24; // 3 months
-
-export const baseUrl = 'https://app.budgetzen.net';
+export const baseUrl = Deno.env.get('BASE_URL') || 'https://app.budgetzen.net';
export const defaultTitle = 'Budget Zen — Simple and end-to-end encrypted budget and expense manager';
export const defaultDescription = 'Simple and end-to-end encrypted budget and expense manager.';
+export const helpEmail = 'help@budgetzen.net';
+
+export const PORT = Deno.env.get('PORT') || 8000;
+export const STRIPE_MONTHLY_URL = 'https://buy.stripe.com/eVa01H57C3MB6CQ14s';
+export const STRIPE_YEARLY_URL = 'https://buy.stripe.com/28o5m1dE896V0es8wV';
+export const STRIPE_CUSTOMER_URL = 'https://billing.stripe.com/p/login/4gw15w3G9bDyfWU6oo';
+export const PAYPAL_MONTHLY_URL =
+ `https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-41N48210MJ2770038MQDVBLI&return_url=${
+ encodeURI(`${baseUrl}/pricing?paypalCheckoutId=true`)
+ }`;
+export const PAYPAL_YEARLY_URL =
+ `https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-20P504881F952811BMQDVA4Q&return_url=${
+ encodeURI(`${baseUrl}/pricing?paypalCheckoutId=true`)
+ }`;
+export const PAYPAL_CUSTOMER_URL = 'https://www.paypal.com';
export interface PageContentResult {
htmlContent: string;
@@ -45,6 +60,7 @@ function basicLayout(htmlContent: string, { currentPath, titlePrefix, descriptio
+
@@ -56,21 +72,22 @@ function basicLayout(htmlContent: string, { currentPath, titlePrefix, descriptio
${loading()}
${header(currentPath)}
-