=== Event Calendar Tickets ===
Contributors: 404ops
Tags: events, calendar, event calendar, recurring events, tickets
Requires at least: 5.9
Tested up to: 7.1
Requires PHP: 8.0
Stable tag: 1.3.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Recurring events, six calendar views, and Stripe ticket sales with QR check-in -- with no artificial free-tier limits.

== Description ==

404ops Events is an events calendar and ticketing plugin. Create a one-off
or recurring event, publish it in any of six calendar views, sell tickets
through Stripe, and check attendees in at the door with a QR scan -- all
without leaving WordPress.

**Free, with no artificial limits:**

* Recurring events on a full RFC 5545 rule subset (daily/weekly/monthly/
  yearly, by-day, intervals, exceptions and extra dates), with per-occurrence
  daylight-saving flags so an ambiguous or nonexistent local time is never
  silently absorbed.
* All six calendar views: Month, Week, Day, List, Photo, and Map.
* Category colors, with a text label alongside every color swatch.
* Ticket sales through Stripe-hosted Checkout -- no card data ever touches
  this plugin or its database -- with capacity limits and QR-code check-in
  at the door.
* A one-click importer from The Events Calendar or All-in-One Event Calendar
  (Time.ly), with a 30-day undo window and permanent URL redirects so search
  rankings and bookmarks never break.
* Import events from a spreadsheet: upload a CSV or tab-separated file
  exported from Eventbrite, Meetup, Google Calendar, Excel or anywhere else.
  Columns are matched by name and shown back to you before anything is
  imported, you choose (or it works out) whether 03/04 means March or April,
  and every row that could not be read is listed by its spreadsheet row
  number in the report afterwards -- with the same 30-day undo window.
* Venues and organizers as their own post types, with a merge tool for
  duplicates.
* Featured, sticky, and hidden events; categories, tags, a filter bar, and
  free-text search.
* Per-event timezone, multi-day and all-day events.
* An iCal feed and per-event iCal download.
* schema.org Event structured data (JSON-LD) on every event page, so search
  engines can show the date, place, attendance mode and ticket price
  directly in results. A recurring event describes the date the page is
  actually showing, and nothing is emitted for a draft, private or
  password-protected event.
* A Gutenberg block and shortcodes for every view.
* An events archive page at your events slug, listing upcoming dates
  (recurring events show every date, not one row per event), paginated, with
  the same filter bar and view switcher as an embedded calendar.
* Ticket confirmation emails and an attendee CSV export.
* Accessible, responsive templates that inherit your active theme and are
  overridable from it.

**No nags, ever.** No admin-notice advertising, no upsell interstitials, no
padlocked free-tier menu items, no review-begging popups. Every Free feature
listed above is genuinely free, with no artificial cap standing between you
and using it -- Pro is named once, on its own Settings tab.

**Pro** adds exactly four things: series passes (one ticket valid across an
entire recurring series), bulk editing across a series, discount codes, and a
ticket waitlist. Nothing else is held back.

The waitlist is for the moment a ticket sells out. Someone leaves their name
and email; when a seat frees up -- today that means an organizer opening up
more places -- the first person in line is emailed and that seat is held for
them for 24 hours, so nobody else can buy it out from under them while they
decide.
If they don't take it in time the seat is released and passes to the next
person. There is no admin screen for the queue in this release: joining,
offering and claiming all happen on their own.

**Optional AI drafting (bring your own connection).** Choose OpenAI,
Anthropic, Google Gemini, OpenRouter, Ollama (local), or an
OpenAI-compatible (custom URL) provider to describe an event or recurring
schedule in plain language and receive a draft for review. Hosted providers
use the API key you supply; Ollama and an OpenAI-compatible endpoint can run
without one when your chosen server permits it. The AI never publishes
anything, never touches money or attendee data, and never creates a venue or
organizer without your explicit, per-item approval -- every draft is propose,
preview, approve, then execute, with a full audit log. Without an AI connection configured, drafting actions make no AI-related request.

== Installation ==

1. Upload the plugin files to `/wp-content/plugins/event-calendar-tickets`, or install
   through the WordPress plugins screen directly.
2. Activate the plugin through the "Plugins" screen in WordPress.
3. A short setup wizard walks you through your first event, or your import
   from The Events Calendar if one is detected.
4. Configure ticketing, AI, and display options under the plugin's own
   Settings menu at any time.

== Frequently Asked Questions ==

= What external services does this plugin contact? =

External requests from this plugin are limited to the configured or explicitly invoked paths listed below. The plugin does not automatically read attendee records for these requests; an administrator's free-form AI description can contain anything that administrator chooses to submit.

* **Stripe** (`api.stripe.com`) is contacted when you connect and test your own Stripe keys, create a ticket Checkout Session, or process a signed payment webhook. Stripe receives the payment and ticket details needed for that operation. Stripe's [Services Agreement](https://stripe.com/legal/ssa) and [Privacy Center](https://stripe.com/privacy) apply.
* **AI drafting** contacts only the provider you choose and configure (OpenAI, Anthropic, Google Gemini, OpenRouter, Ollama, or an OpenAI-compatible URL), only when you use drafting. It sends the description and draft fields you submit; it does not automatically read attendee records. OpenAI [Services Agreement](https://openai.com/policies/services-agreement/) and [Privacy](https://openai.com/policies/privacy-policy/), Anthropic [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) and [Privacy](https://www.anthropic.com/legal/privacy), Google Gemini [Terms](https://ai.google.dev/gemini-api/terms) and [Privacy](https://policies.google.com/privacy), and OpenRouter [Terms](https://openrouter.ai/terms) and [Privacy](https://openrouter.ai/privacy) apply to those hosted providers. For Ollama, review your server operator's terms and privacy policy; for a custom URL, review the terms and privacy policy of the operator of that OpenAI-compatible custom URL before configuring it.
* **AI Draft URL fetch** retrieves page text from the URL you paste, only when you request that draft. The destination site's terms and privacy policy apply.
* **Map tiles:** If you turn on map tiles -- off by default -- every visitor's browser requests map tiles directly from the host in the tile URL you configure. The configured tile host's terms and privacy policy apply.
* **Address geocoding:** If you turn on address geocoding -- also off by default -- each click sends the address to the fixed OpenStreetMap Nominatim endpoint (`nominatim.openstreetmap.org`). See Nominatim's [usage policy](https://operations.osmfoundation.org/policies/nominatim/) and OpenStreetMap Foundation [Privacy Policy](https://osmfoundation.org/wiki/Privacy_Policy).
* **Anonymous usage statistics** remain local unless you opt in and configure the `ops404_events_telemetry_endpoint` filter. If both are enabled, counts, settings flags, version and environment metadata are sent to that endpoint; review its operator's terms and privacy policy. The default endpoint is empty, so no telemetry is sent by default.

= Are there any limits? =

Occurrences of a repeating event are generated 24 months ahead and extended
automatically. Calendar and REST requests return at most 200 occurrences per
page, as do the Attendance and Upcoming Events Digest report screens - both
tell you the full total when there is more than fits, so a capped screen is
never silent. CSV exports are complete: they are read in batches rather than
truncated. The one exception is the Attendees screen export, which is capped
at 10,000 rows - and it tells you on screen when a filter actually matches
more than that, including how many are left out. See below. The migration importer processes 100 items per
batch and can be resumed. Import rollback is available for 30
days; URL redirects from imported events are kept permanently so your links
keep working.

= Where is my data stored? =

Everything lives in this site's own database: events, venues, and organizers
as ordinary WordPress posts; the calendar schedule, tickets, orders, and
attendees in the plugin's own tables (`wp_ops404_events_*`). Money is stored
as whole cents, never a float. Stripe API keys and webhook signing secrets
are encrypted at rest and are never written to a log, error message, or
export in plaintext. Card numbers are never stored here at all -- Stripe
holds those; this plugin only keeps a transaction reference.

= Is there a public REST API or calendar feed? =

Yes, both read-only and both cover only public events (a draft, private,
password-protected, or "hide from calendar" event never appears in either):

* `GET /wp-json/ops404-events/v1/events` -- occurrences in a date range.
  `per_page` defaults to 50 and is capped at 200; a request asking for more
  is rejected rather than silently truncated.
* `GET /wp-json/ops404-events/v1/occurrences/{id}` -- a single occurrence.
* An iCal subscription feed and single-event download, at `/?ops404_events_ical=1`
  (also reachable at `/events/ical/`) and `/?ops404_events_ics={id}`.

Both the REST routes and the iCal feed can be turned off entirely from
Settings -> Advanced.

= I migrated from The Events Calendar or All-in-One Event Calendar. Can I undo it? =

Yes, for 30 days after the import finishes. Rolling back removes the events,
venues, organizers, and imported ticket history that import batch created --
it never touches the source plugin's own data, and it refuses outright if
a ticket, order, or attendee has been recorded against an imported event
since the import ran, so you can never lose a real sale to an undo button.

**Redirect mappings (old-URL -> new-URL) are kept permanently, even after a
rollback** -- so search engines and bookmarked links never break, no matter
how long ago you rolled the import back. Only the *ability* to roll back
expires after 30 days; the redirects themselves do not.

All-in-One Event Calendar has no venue or organizer post type of its own --
they are plain fields repeated on every event, so the importer builds one
venue/organizer per distinct name it finds (an exact case/whitespace match
merges; a different-looking name, like "Roxy Theatre" next to "The Roxy",
never silently merges with it). Recurring events are checked against
All-in-One Event Calendar's own materialized occurrence history before being
trusted as a series; a pattern that doesn't check out imports as a single
event instead, with a note explaining why, rather than risk a silently wrong
calendar. Ticket types and attendees for an event sold through All-in-One
Event Calendar's paid tickets are not migrated -- that history lives only at
api.time.ly, not in this site's own database, and is unreachable from here.

= How do attendee tickets and door check-in work? =

Every paid or free ticket issues one attendee record with its own unguessable
32-character code, minted from a cryptographically random source -- never
derived from the order or any other id. That code is rendered as a QR code
entirely locally (no third-party QR-generation service ever sees it) and
embedded directly in the confirmation email as an inline image; nothing about
your tickets is ever sent to an outside service to produce them.

The Attendees screen lists every attendee with filters and a CSV export,
capped at 10,000 rows per export. A filter matching more than that says so
on screen, with the real total and how many rows are left out, so you never
find out by counting the file. The export keeps the earliest registrations
and omits the most recent, so exporting again after narrowing the filters
never shifts or repeats the rows you already have. Attendee names and emails
are neutralised against CSV formula injection before export.

The Check-in screen scans or accepts a typed ticket code and requires
`manage_options` by default, same as every other screen in this plugin -- with
ONE documented exception: a site can delegate door duty to another role via
the `ops404_events_checkin_capability` filter (defaults to `manage_options`),
so door staff do not need full admin access. No other screen, action, or
capability check in this plugin can be changed this way.

= What happens to my data if I uninstall the plugin? =

Nothing, by default. Deleting 404ops Events through the Plugins screen
leaves every event, ticket, order, attendee, and setting exactly where it
was, in case you reinstall it later. If you want a completely clean removal
instead, turn on "Remove all data on uninstall" under Settings -> Advanced
first -- with that toggle on, uninstalling drops every table this plugin
created, every option and cron job it scheduled, and every event, venue, and
organizer post (with their meta), permanently.

= Can I refund a ticket? =

Refunds are performed from your own Stripe dashboard; this plugin marks the
order refunded automatically once Stripe confirms it, so your records stay
accurate without a separate step here. The attendee's ticket stays valid:
refunding in Stripe does not currently cancel their admission or free the
seat for someone else.

== Screenshots ==

= 1. Your next events at a glance, with 30-day activity and this month's sales. =

= 2. All events in one list, with dates, venues and status. =

= 3. Attendees and ticket holders, filterable and exportable to CSV. =

= 4. Ticket sales, revenue, capacity, and sell-through by event. =

== Changelog ==

= 1.3.3 =
* Fix: Restore the intended layout and styling across calendar views, event editing, AI drafting, and door check-in after the plugin's public CSS class rename.

= 1.3.2 =
* Clarified the external-service data, trigger, and policy disclosures.

= 1.3.1 =
* Fix: Pro update checks select this plugin's uploaded package and remove stale update offers when licensing or release lookup fails.
* Fix: WordPress Pro updates require a compatible Free version, with update protection still registered when Free is missing or older.
* Maintenance: Keep the Free and Pro versions aligned for the updated add-on.

= 1.3.0 =
* Fix: recurring events created with an "Until" end date regain their final
  occurrence. The end date was stored as midnight and compared as an
  inclusive cutoff, so an event on its own last day fell just outside it --
  no date on the calendar, and no ticket on sale. Rules already saved are
  repaired automatically on upgrade; nothing to re-save, and events that
  looked finished were never going to prompt anyone to check.
* New (Pro): a ticket waitlist. When a ticket sells out, visitors can leave
  their name and email instead of hitting a dead end.
* When a seat frees up -- today, an organizer raising a ticket's capacity --
  the first person waiting is emailed and the seat is HELD for
  them for 24 hours (filterable). A held seat is counted as taken everywhere
  else in the plugin, so it cannot be sold to anyone else while the hold
  lasts, and the person it is held for can buy it by checking out with the
  email address they joined with.
* An unclaimed hold releases its seat the moment its window closes, whether
  or not WP-Cron ever runs; an hourly job then passes it to the next person
  in line.
* Waitlist entries are names and email addresses of people who did not buy
  anything, so they are included in WordPress's own personal-data export and
  are deleted outright by an erasure request, and removed with everything
  else if you uninstall with "Remove all data" enabled.
* Not in this release: there is no admin screen for browsing the queue, no
  queue-position display, and a refund does not yet free a seat by itself
  (attendee tickets stay valid after a refund, as they always have) -- so the
  freed-seat path that works today is a capacity increase.

= 1.2.0 =
* New: import events from a CSV or tab-separated spreadsheet -- the path off
  Eventbrite, Meetup, or a calendar that has always lived in a spreadsheet,
  rather than off a competing WordPress plugin. Columns are matched by name
  (title and a start date are required; venue, organizer, end date, timezone,
  cost and description are used when present) and the match is shown back to
  you before anything is imported.
* The day/month order is decided once for the whole file and shown back to
  you: 03/04/2026 is 3 April or 4 March depending on where the spreadsheet
  came from, both are valid dates, and guessing wrong produces a calendar
  that is a month out and reports nothing. If the file contains a date that
  can only be read one way it settles the question by itself; otherwise you
  choose on the upload form.
* Every row that could not be imported is counted and listed by its
  spreadsheet row number, with the reason, in the migration report and its
  CSV export -- a bad date in one row never silently drops that event and
  never stops the rest. Rows naming a recurrence import as single events with
  a stated reason: a spreadsheet cannot describe a repeating schedule
  precisely enough to rebuild one, and a wrong series is worse than no series.
* Large files are read a row at a time rather than loaded whole, so a
  several-thousand-row export does not exhaust the memory limit mid-import.
  A date column with no times imports as all-day events instead of a calendar
  of midnight events lasting no time at all.

= 1.1.0 =
* New: a one-click importer from All-in-One Event Calendar (Time.ly), the
  plugin removed from wordpress.org in 2025 with 80,000+ active installs and
  no maintained successor. Same 30-day undo window and permanent URL
  redirects as the existing The Events Calendar importer. Deduplicates
  venues and organizers (AI1EC stores them as plain fields on every event,
  not their own post type) and checks every recurring event's translated
  schedule against AI1EC's own materialized occurrence history before
  trusting it as a series, rather than risk a silently wrong calendar.
  Ticket types and attendees sold through AI1EC's paid tickets are not
  migrated -- that history lives only at api.time.ly.

= 1.0.3 =
* New (Pro): a quiet, dismissible admin notice now appears once this add-on's licence hasn't revalidated successfully in a few days, before Pro features would otherwise silently lapse with no warning. Updated the shared licence client.
* Fix (Pro): a licence's cached "valid" state is now bounded to 14 days since its last successful check, so a site whose WP-Cron never runs no longer keeps a revoked or expired licence reading active indefinitely.

= 1.0.2 =
* Fix (Pro): licence activation and update checks used the wrong authentication scheme and an incorrect product filter against the licence server, which could reject valid requests. Updated the shared licence client.

= 1.0.1 =
* Fix (Pro): first-time licence activation could fail against the real licence server on a brand-new key (updated the shared licence client).

= 1.0.0 =
* Initial release.

== Upgrade Notice ==

= 1.0.3 =
Pro now shows an early warning notice before licence-related features would lapse, and no longer keeps a revoked or expired licence active forever on a site whose WP-Cron never runs. Pro users should update.

= 1.0.2 =
Fixes Pro licence activation and update checks against the real licence server. Pro users should update.

= 1.0.1 =
Fixes first-time Pro licence activation. Pro users on a licence that has never activated before should update.

= 1.0.0 =
Initial release.
