SignPayGo
Developer Project Brief
All pages are currently static HTML prototypes. Browse every page and interaction at the URL above before writing any code. Implement each as an MVC controller action + Razor view.
| HTML File | Route / Controller | Notes |
|---|---|---|
| signpaygo-dashboard.html | DashboardController / Index | Main landing after login. Shows event stats, recent activity, quick-create CTA. |
| signpaygo-create-event.html | EventsController / Create | Multi-section form: details, category, waiver editor, reminders, pricing. POST to EventsController/Store. |
| signpaygo-participants.html | EventsController / Participants | Participant list for a specific event. Linked from create-event on publish. |
| signpaygo-templates.html | TemplatesController / Index | Browse My Templates + Starter Templates grid. |
| signpaygo-create-template.html | TemplatesController / Create | Full waiver editor for building a reusable template. POST to TemplatesController/Store. |
| signpaygo-payouts.html | PayoutsController / Index | Stripe payout history and balance. Read from Stripe Connect API. |
| signpaygo-staff.html | TeamController / Index | Team member management. Roles: Owner, Admin, Staff, Viewer. |
| signpaygo-settings.html | SettingsController / Index | Tabbed: Profile, Organization, Integrations, Billing, Notifications. |
The Integrations tab contains an API & Webhooks section. Hide this entire section using CSS in v1 — it will be surfaced in v2.
#api-webhooks-section { display: none; }
Use snake_case table and column names throughout. Never use full_name — always split into first_name and last_name as separate columns. All tables include the following four audit columns (omitted from each table below for brevity):
created_by UUID FK → users(id) | created_on TIMESTAMPTZ NOT NULL DEFAULT now()
modified_by UUID FK → users(id) | modified_on TIMESTAMPTZ NOT NULL DEFAULT now()
Use UUID primary keys everywhere (gen_random_uuid()).
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | gen_random_uuid() |
| VARCHAR(255) | UNIQUE NOT NULL — used as login identifier | |
| first_name | VARCHAR(100) | NOT NULL |
| last_name | VARCHAR(100) | NOT NULL |
| avatar_url | TEXT | Optional profile image URL |
| google_sub | VARCHAR(255) | Google Sign-In subject ID — UNIQUE, nullable |
| password_hash | TEXT | bcrypt hash — NULL when using Google SSO only |
| is_active | BOOLEAN | DEFAULT true — set false on account closure |
| last_login_at | TIMESTAMPTZ | Updated on every successful auth |
One user can belong to many orgs; one org can have many users (via org_members).
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| name | VARCHAR(255) | Organization / club / school name |
| logo_url | TEXT | Uploaded logo for branding on waivers |
| stripe_account_id | VARCHAR(255) | Stripe Connect Express account ID — for receiving payments from parents |
| stripe_customer_id | VARCHAR(255) | Stripe Customer ID — used to charge the org for their SignPayGo subscription |
| stripe_onboarding_done | BOOLEAN | DEFAULT false — flipped after Stripe onboarding completes |
| default_category | VARCHAR(50) | Default event category for this org |
| plan | VARCHAR(50) | DEFAULT 'free' — values: free, pro, team, enterprise |
| plan_billing_price_cents | INTEGER | Subscription amount in cents (e.g. 94800 = $948/yr) |
| plan_last_billed_on | DATE | Date of the most recent successful subscription charge |
| plan_next_bill_on | DATE | Date the next subscription charge will be attempted |
| cc_fee_percent | NUMERIC(5,2) | DEFAULT 3.90 — credit card processing rate applied to parent payments. Editable per org for custom pricing agreements. |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| org_id | UUID FK | REFERENCES organizations(id) ON DELETE CASCADE |
| user_id | UUID FK | REFERENCES users(id) ON DELETE CASCADE |
| role | VARCHAR(50) | Values: owner, admin, staff, viewer |
| invited_at | TIMESTAMPTZ | |
| accepted_at | TIMESTAMPTZ | NULL until invite is accepted |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| org_id | UUID FK | REFERENCES organizations(id) |
| created_by | UUID FK | REFERENCES users(id) |
| title | VARCHAR(255) | NOT NULL |
| category | VARCHAR(50) | sports, field-trip, camp, arts, religious, general |
| description | TEXT | Optional rich text |
| location | TEXT | |
| starts_at | TIMESTAMPTZ | |
| ends_at | TIMESTAMPTZ | |
| slug | VARCHAR(120) | UNIQUE — URL-safe slug for the public sign-up page |
| status | VARCHAR(30) | draft, active, completed, cancelled |
| participant_mode | VARCHAR(20) | list, self, both |
| waiver_content | TEXT | HTML from the WYSIWYG editor including field-token spans |
| price_cents | INTEGER | NULL = free event. Stored in cents. |
| tax_percent | NUMERIC(5,2) | e.g. 8.25 |
| processing_fee_mode | VARCHAR(20) | absorb, pass_on |
| deposit_enabled | BOOLEAN | DEFAULT false |
| deposit_cents | INTEGER | NULL when deposit_enabled = false |
| balance_due_at | DATE | Date remainder is due |
| template_id | UUID FK | NULL if not started from a template |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| event_id | UUID FK | REFERENCES events(id) ON DELETE CASCADE |
| send_at | DATE | Absolute date the reminder fires |
| is_enabled | BOOLEAN | DEFAULT true |
| sent_at | TIMESTAMPTZ | NULL until actually sent |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| event_id | UUID FK | REFERENCES events(id) ON DELETE CASCADE |
| child_first_name | VARCHAR(100) | NOT NULL |
| child_last_name | VARCHAR(100) | NOT NULL |
| parent_first_name | VARCHAR(100) | NOT NULL |
| parent_last_name | VARCHAR(100) | NOT NULL |
| parent_email | VARCHAR(255) | |
| parent_phone | VARCHAR(50) | |
| status | VARCHAR(30) | pending, signed, paid, signed_paid, cancelled |
| signed_at | TIMESTAMPTZ | NULL until waiver is submitted |
| paid_at | TIMESTAMPTZ | NULL until payment confirmed |
| amount_paid_cents | INTEGER | Total amount charged to parent (base price + processing fee) |
| fee_cents | INTEGER | Credit card processing fee portion of the charge — for reporting. Calculated as round(price_cents × cc_fee_percent / 100) at checkout. |
| stripe_payment_id | VARCHAR(255) | Stripe PaymentIntent ID |
| fingerprint_id | VARCHAR(255) | FingerprintJS visitor ID for fraud prevention |
| ip_address | INET | Captured at time of signing |
| device_info | JSONB | User agent, screen size, etc. |
| field_responses | JSONB | Key/value map of all waiver field answers |
| signature_data | TEXT | Base64 PNG of drawn signature |
| initials_data | TEXT | Base64 PNG of drawn initials |
| signed_pdf_path | TEXT | Cloud storage path to the locked signed PDF — e.g. waivers/{org_id}/{event_id}/{participant_id}.pdf. All three path segments are UUID v4 — never a sequential integer. Never changes after creation. |
| signed_pdf_url | TEXT | Pre-signed or CDN download URL with a short expiry (e.g. 15 minutes). Pre-signed URLs are the primary access control layer — even knowing the storage path does not grant access because the bucket is private. Regenerate from signed_pdf_path on demand when the URL expires. |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| org_id | UUID FK | REFERENCES organizations(id) ON DELETE CASCADE |
| created_by | UUID FK | REFERENCES users(id) |
| name | VARCHAR(255) | NOT NULL |
| description | TEXT | |
| category | VARCHAR(50) | Same category enum as events |
| visibility | VARCHAR(20) | private, team |
| content | TEXT | HTML body including field-token spans |
| use_count | INTEGER | DEFAULT 0 — incremented each time applied to an event |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| org_id | UUID FK | |
| event_id | UUID FK | NULL for non-event emails |
| participant_id | UUID FK | NULL for broadcast emails |
| to_email | VARCHAR(255) | |
| subject | VARCHAR(500) | |
| template_key | VARCHAR(100) | invitation, reminder, receipt, signup_confirm, waiver_complete |
| provider_msg_id | VARCHAR(255) | SendGrid / SES message ID for tracking correlation |
| status | VARCHAR(30) | queued, sent, delivered, opened, clicked, bounced, spam |
| opened_at | TIMESTAMPTZ | |
| clicked_at | TIMESTAMPTZ | |
| bounced_at | TIMESTAMPTZ | |
| sent_at | TIMESTAMPTZ |
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| org_id | UUID FK | |
| stripe_payout_id | VARCHAR(255) | Stripe Payout object ID |
| amount_cents | INTEGER | |
| currency | VARCHAR(10) | DEFAULT 'usd' |
| arrival_date | DATE | |
| status | VARCHAR(30) | pending, paid, failed, cancelled |
| description | TEXT |
Each organization connects their own Stripe account via Stripe Connect Express. SignPayGo acts as the platform account; orgs are connected accounts. Funds flow: parent → Stripe → org's connected account (minus platform fee).
/settings/stripe/callback.stripe_account_id and stripe_onboarding_done on the organizations table.transfer_data.destination pointing to the connected account.payment_intent.succeeded, charge.refunded, payout.paid, etc.).The parent-facing signing page must support all three of the following payment methods. Stripe handles the rendering and authentication for each — no third-party SDKs required beyond the Stripe JS library.
| Method | Implementation | Notes |
|---|---|---|
| Credit & Debit Card | Stripe Elements — CardElement or Payment Element |
Visa, Mastercard, Amex, Discover. Use Stripe Payment Element (not the legacy Card Element) — it handles all three methods in a single UI component with automatic method detection. |
| Apple Pay | Built into Stripe Payment Element | Automatically shown on Safari / iOS when the device has Apple Pay configured. No additional code — Stripe handles domain verification and the payment sheet. Requires the domain to be registered in the Stripe dashboard. |
| Google Pay | Built into Stripe Payment Element | Automatically shown on Chrome / Android when the browser has a saved card. No additional code — handled entirely by Stripe Payment Element. Works on desktop Chrome and Android Chrome. |
SignPayGo passes the credit card processing fee to the parent at checkout. The fee rate is configurable per organization to accommodate custom pricing agreements.
organizations.cc_fee_percent — type NUMERIC(5,2), default 3.90.fee = round(event_price_cents × (cc_fee_percent / 100)). Never calculate this client-side.event_price_cents + fee_cents.participants.amount_paid_cents holds the total charged; add a participants.fee_cents column to record the processing fee portion for reporting.cc_fee_percent in Settings → Organization → Pricing & Billing. Changes apply only to new events — do not retroactively change fees on existing participants.events.price_cents = 0) skip payment entirely — no Stripe charge, no fee calculation.Full refunds are initiated by the organizer from the participant view modal. The UI confirmation is already implemented in the frontend. The server-side implementation must:
/events/{eventId}/participants/{participantId}/refund — authenticated, organizer-only.signed_paid and that a stripe_payment_id exists on the record before attempting the refund.stripe.Refunds.Create with PaymentIntent = participants.stripe_payment_id. Do not implement partial refunds in v1.participants.status = 'cancelled', set participants.signed_pdf_path and signed_pdf_url to NULL (the PDF is voided but the S3 object is retained under WORM lock for legal audit purposes — do not delete it from S3).email_log table.refund_confirmation email template in v1).users row with google_sub set, password_hash NULL.google_sub first, then fall back to email (handle account-merge edge case).google_sub as a UNIQUE column. Do not rely on email alone — users can change their Google email./login and /register pages only.visitorId before form submission and include it in the waiver POST payload.fingerprint_id on the participants table.visitorId server-side using the FingerprintJS Server API — retrieve confidence score, incognito flag, and bot detection result.Use SendGrid as the primary provider (preferred) or AWS SES as fallback. Route all outbound email through a single IEmailService abstraction so the provider can be swapped without touching calling code. Track opens, clicks, and bounces via SendGrid Event Webhooks or SES SNS notifications. Write every outbound email to the email_log table including provider_msg_id. On bounce, mark the participant's email as invalid and surface a warning in the participants view.
There are three parent-facing transactional templates plus supporting organizer and system templates. The three parent templates are HTML files checked into the repository and rendered server-side with template variable substitution.
| Template Key | File | Sent To | Trigger |
|---|---|---|---|
invitation | email-invitation.html | Parent | Organizer publishes event and adds participants — see 3.4.2 |
reminder_sign_pay | email-reminder-sign-pay.html | Parent | Scheduled reminder fires and participant has NOT signed AND NOT paid — see 3.4.3 |
reminder_payment_only | email-reminder-payment-only.html | Parent | Scheduled reminder fires and participant HAS signed but has NOT paid — see 3.4.3 |
receipt | (TBD) | Parent | Stripe payment confirmed — attach signed waiver PDF |
signup_confirm | (TBD) | Parent | Parent self-registers via the public sign-up link |
waiver_complete | (TBD) | Organizer | All participants in an event have reached signed_paid status |
The invitation email (email-invitation.html) is the very first email a parent receives. It is sent once per participant when the organizer publishes and sends the event. It is never sent again — subsequent emails are always reminders, not invitations.
pending (no prior invitation sent). Check email_log for an existing invitation row for this participant_id to prevent duplicates.sign_pay_url (the public signing link scoped to this participant).{{parent_first_name}}, {{child_first_name}}, {{child_last_name}}, {{event_name}}, {{org_name}}, {{event_dates}}, {{event_location}}, {{deadline_date}}, {{amount_due}}, {{sign_pay_url}}, {{organizer_name}}, {{organizer_email}}, {{unsubscribe_url}}.Reminders are configured by the organizer when creating or editing an event. Each reminder is stored as a row in event_reminders with a send_at date and an is_enabled flag. A background job (Hangfire) runs daily and dispatches any reminders whose send_at date matches today and whose sent_at is still NULL.
The reminder template is chosen dynamically per participant based on their current status at the time the job runs:
| Participant status | Template sent | Reasoning |
|---|---|---|
pending — not signed, not paid | reminder_sign_pay | Participant needs to do both steps. Email covers signing and payment together. |
signed — signed but not paid | reminder_payment_only | Waiver is on file. Email acknowledges this and focuses only on the outstanding payment. |
signed_paid — fully complete | No email sent | Nothing to do. Skip this participant silently. |
cancelled | No email sent | Do not contact cancelled participants. |
After sending each batch for a reminder row, set event_reminders.sent_at = NOW() to prevent re-sending. Log each individual email to email_log with the correct template_key.
The organizer configures reminder dates on the Create Event / Edit Event page in the Reminders section of the form. Each date the organizer sets creates one row in event_reminders. Reminders can be individually toggled on/off via is_enabled without deleting the row.
send_at date is in the past and sent_at is still NULL (e.g. the job was down), skip it — do not send late reminders retroactively.All three parent-facing templates share the same variable naming convention. Populate them server-side before sending. The sign_pay_url must be a unique, participant-scoped URL — never a generic event link.
| Variable | Source |
|---|---|
{{parent_first_name}} | participants.parent_first_name |
{{child_first_name}} | participants.child_first_name |
{{child_last_name}} | participants.child_last_name |
{{event_name}} | events.title |
{{org_name}} | organizations.name |
{{event_dates}} | Formatted from events.start_date / events.end_date |
{{event_location}} | events.location |
{{deadline_date}} | Formatted from events.deadline |
{{amount_due}} | Formatted from events.price_cents (e.g. "$285.00") |
{{sign_pay_url}} | Generated URL: https://signpaygo.com/r/{event_slug}/?p={participant_id} |
{{pay_url}} | Same as above but deep-links to the payment step (reminder_payment_only only) |
{{organizer_name}} | users.first_name + last_name of the event owner |
{{organizer_email}} | users.email of the event owner |
{{unsubscribe_url}} | Generated unsubscribe link scoped to participant_id |
Zapier integration is deferred to v2. No work required in v1. When implemented, it will fire outbound webhook triggers on key participant and event lifecycle events (signed, paid, completed). The Settings → Integrations tab shows Zapier with a "Coming Soon" badge and a disabled "Notify Me" button.
Make.com integration is planned for v2. No work required in v1.
Past templates currently load from PastTemplates.js. In the MVC implementation they must come from the waiver_templates table, scoped to the logged-in user's organization.
SELECT * FROM waiver_templates WHERE org_id = @orgId ORDER BY created_at DESCuse_count and updated_at.use_count and set updated_at.<script src="PastTemplates.js"> tag and replace with a call to GET /api/templates/past (or a Razor partial populated server-side).The shared SignPayGo Library templates can remain as a static JSON file served from wwwroot for v1. No database table required.
wwwroot/data/TemplateLibrary.jsonfetch('/data/TemplateLibrary.json') — no change to front-end logic.spg_lib_disclaimer_agreed) remain as-is on the client.When a parent submits a signed waiver, the server immediately generates a locked PDF that acts as the permanent legal record. The events.waiver_content column remains the live editable version — the PDF is the frozen-at-signing snapshot. The site owner changing their waiver template after the fact has no effect on previously signed documents.
/sign/{slug}).waivers/{org_id}/{event_id}/{participant_id}.pdf — all three IDs are UUID v4 (never sequential integers). A UUID has ~122 bits of entropy, making path enumeration attacks computationally infeasible. Never substitute a numeric or auto-increment ID in this path.participants.signed_pdf_path and participants.signed_pdf_url.Use AWS S3 (Simple Storage Service) for signed waiver PDF storage. S3 is object storage — files are written once, retrieved by path, and served via pre-signed URL. It is the correct service for this pattern. Do not use EFS (file system), EBS (block storage attached to a single EC2 instance), or RDS (database) for PDF storage.
Create a dedicated private bucket for signed waivers (e.g. signpaygo-waivers-prod). Apply all of the following settings at bucket creation — some cannot be changed after PDFs have been written.
| Setting | Value | Why |
|---|---|---|
| Block Public Access | All four options enabled | No file in this bucket is ever served directly. All access goes through pre-signed URLs. This must be on even if object policies look correct — it is a hard override. |
| Versioning | Enabled | Required by Object Lock. Also protects against accidental overwrites. |
| Object Lock | Enabled — Compliance mode | WORM (Write Once Read Many). In Compliance mode, not even the AWS root account can delete or overwrite a locked object before the retention period expires. Governance mode allows admins to override — do not use it for signed legal documents. |
| Object Lock retention period | 7 years (2555 days) | Matches the lifecycle rule. PDFs are immutable for the full retention window. |
| Server-side encryption | SSE-S3 (AES-256, AWS-managed keys) | Encrypts data at rest. SSE-S3 is simpler to operate than SSE-KMS and sufficient for this use case unless compliance requires customer-managed keys. |
| ACLs | Disabled (Bucket Owner Enforced) | ACLs are a legacy access model. Disabling them ensures all access is controlled exclusively through bucket policies and IAM — no object-level ACL confusion. |
| Transfer acceleration | Optional | Enable if parents are geographically distributed and upload latency is a concern. Not required on launch. |
s3:PutObject, s3:GetObject, s3:HeadObject scoped to the waivers bucket only.s3:DeleteObject permission. Deletes are prevented by Object Lock at the storage layer, but removing the IAM permission adds a second line of defense.waivers/{uuid}/{uuid}/{uuid}.pdf), the bucket is private and the path alone grants no access.participants.signed_pdf_url expires, regenerate the pre-signed URL from participants.signed_pdf_path on demand. Do not re-generate the PDF itself.waivers/) so all signed PDFs are covered automatically.org_members.role: owner, admin, staff, viewer./dashboard, /events, /templates, /settings require authentication./sign/{slug}) is unauthenticated.| Variable | Purpose |
|---|---|
| DATABASE_URL | PostgreSQL connection string |
| STRIPE_SECRET_KEY | Platform Stripe secret key |
| STRIPE_WEBHOOK_SECRET | For verifying Stripe webhook signatures |
| GOOGLE_CLIENT_ID | OAuth client ID |
| GOOGLE_CLIENT_SECRET | OAuth client secret |
| SENDGRID_API_KEY | Or AWS SES credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) |
| FINGERPRINTJS_SECRET_KEY | Server-side verification key |
| FINGERPRINTJS_PUBLIC_KEY | Client-side public key |
Recommended build order to minimize rework:
events.waiver_content.PastTemplates.js to the waiver_templates table./sign/{slug}).participants.
Questions? Contact the product team before making architectural decisions that deviate from this brief.
SignPayGo — Confidential — Internal Use Only