=== Kramar - Toolkit for eCommerce: Invoices, Email, Compliance & More ===
Contributors: naiches
Tags: invoices, gdpr, abandoned cart, variation swatches, back in stock
Requires at least: 7.0
Tested up to: 7.0
Requires PHP: 8.4
Stable tag: 1.0.2
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

PDF invoices, email builder, EU/NL compliance, checkout editor, custom order statuses & more — one modular toolkit for WooCommerce.

== Description ==

Kramar is a modular WooCommerce toolkit built for EU compliance and performance. Instead of installing a dozen separate single-purpose plugins, Kramar provides a focused set of tools in one clean package — 27 modules across ten areas. Every module is opt-in and disabled by default, so you only run what you actually use.

**About the name:** *Kramar* (крамар) is an old Ukrainian word for a *merchant* — the town shopkeeper who dealt in wares. Its root, *kram* (goods), is a medieval German trade-loanword that travelled east into the Slavic languages, a small echo of Europe's old merchant network. Kramar is the toolkit that equips the merchant.

= What's included =

* **EU & NL compliance** — Legal consent checkboxes, EU payment-obligation order-button text (Dutch Hoge Raad ruling), Omnibus 30-day lowest-price display, unit-price / delivery-time / tax notices, and a 14-day right-of-withdrawal / return flow.
* **PDF documents** — Article 226-compliant invoices, packing slips, and credit notes, with sequential numbering and encrypted storage.
* **Email** — A drag-and-drop email template builder, custom transactional emails with status / time-delay / manual triggers, and a developer API.
* **Checkout** — A checkout field editor (classic + block checkout), address validation, EU VAT ID format validation for B2B orders, and a delivery-date picker.
* **Order management** — Custom order statuses, sequential order numbers, and a refund / return workflow.
* **Products** — Variation swatches (colour or image), minimum / maximum / step quantity rules, extra product option fields, and enhanced reviews (verified-buyer badge, helpful voting, rating breakdown).
* **Marketing** — Google Shopping product feed, back-in-stock notifications, abandoned-cart recovery, and post-purchase review reminders — all consent-first.
* **Customer account** — An enhanced My Account dashboard with order tracking, one-click reorder, and a smoother registration flow.
* **Shipping** — Weight-based shipping rates.
* **Performance** — Disable cart-fragment polling, dequeue unused WooCommerce CSS/JS, and strip WooCommerce admin bloat.
* **Privacy** — Order and review IP anonymisation, data-retention warnings, and scheduled PII cleanup.

= Built for the EU =

Kramar was designed from the ground up for EU and Dutch law. The compliance modules consider the GDPR, the Consumer Rights Directive, the Omnibus Directive, and Dutch civil-code requirements. They are entirely optional — the invoicing, email, product, performance, and order tools work for any store, anywhere.

= Modular =

Enable only what you need. Disabled modules have zero overhead — no hooks, no queries, no assets loaded.

== Installation ==

1. Install and activate WooCommerce (required).
2. Upload the `kramar` folder to `/wp-content/plugins/`, or install it from the Plugins screen.
3. Activate the plugin.
4. Open the **Kramar** menu in your admin sidebar and enable the modules you want.

== Frequently Asked Questions ==

= Does Kramar require WooCommerce? =

Yes. WooCommerce must be installed and active — Kramar declares it as a required plugin and will not run without it.

= Do I have to use every module? =

No. Every module is opt-in and disabled by default. A disabled module registers no hooks, runs no queries, and loads no assets.

= Are the PDF invoices legally compliant? =

Invoices include the fields required by Article 226 of the EU VAT Directive and use gap-free sequential numbering. Please confirm the output meets your obligations with your own accountant, as requirements vary by country and business type.

= Where are generated PDFs stored? =

They are stored encrypted inside your site's uploads directory and are served only through authenticated, capability-checked download endpoints — never from a public URL.

= Can I use Kramar outside the EU? =

Yes. The compliance modules are optional. The invoicing, email, product, performance, and order-management tools work for any WooCommerce store.

= Can I send a custom email from my own code? =

Yes. Create the email in **Kramar → Emails → Add New Email**, then trigger it programmatically with `kramar_send_email()` or `kramar_schedule_email()`. See the "For Developers" section below.

== For Developers ==

Kramar's Email Manager module exposes a small PHP API for sending and scheduling custom emails from themes or other plugins. No code is needed to *create* a custom email — use Kramar → Emails → Add New Email in the admin to build and configure it. Once it exists you can trigger it programmatically.

= Finding a custom email's id =

Every custom email created through the UI gets an id of the form `kramar_custom_{slug}`, where the slug is derived from the email name: spaces become underscores, all characters are lowercased, and the result is passed through `sanitize_key()`. Example: an email named "Review Request" becomes `kramar_custom_review_request`. The exact id is shown in the Custom Emails table on Kramar → Emails.

= Sending immediately =

    kramar_send_email( string $id, mixed $context = null ): bool

Sends the custom email right now. Returns `true` if the email was dispatched (the email was found, is enabled, and passed the send filters). `$context` may be a `WC_Order` object, an order id (integer or numeric string), or `null`.

    kramar_send_email( 'kramar_custom_review_request', $order_id );

= Scheduling for later =

    kramar_schedule_email( string $id, mixed $context, int $timestamp ): bool

Schedules the email via Action Scheduler at the given Unix timestamp. Returns `true` if the action was queued (or was already queued). Requires WooCommerce's bundled Action Scheduler.

    kramar_schedule_email( 'kramar_custom_review_request', $order_id, time() + 7 * DAY_IN_SECONDS );

= Idiomatic do_action aliases =

Both functions are also wired to `do_action` so callers that prefer hooks over direct function calls can use:

    do_action( 'kramar_send_email', $id, $context );
    do_action( 'kramar_schedule_email', $id, $context, $timestamp );

= Filters =

**`kramar_email_recipient`** — Override the To address before the email is sent.

    apply_filters( 'kramar_email_recipient', string $recipient, string $id, WC_Order|null $order )

**`kramar_email_should_send`** — Return `false` to prevent a send entirely.

    apply_filters( 'kramar_email_should_send', bool $should, string $id, WC_Order|null $order )

**`kramar_email_merge_field_values`** — Add or replace merge-field values used inside email content.

    apply_filters( 'kramar_email_merge_field_values', array $replacements, WC_Order $order )

= Worked example: review request 7 days after order completion =

    add_action( 'woocommerce_order_status_completed', function ( $order_id ) {
        kramar_schedule_email( 'kramar_custom_review_request', $order_id, time() + 7 * DAY_IN_SECONDS );
    } );

**Note:** The WooCommerce mailer caches its email list per request. A custom email created in the same PHP request as your `kramar_send_email()` call will not yet be registered and the call will return `false`. In normal use — emails created in the admin, triggered later by frontend or cron events — this is never a concern.

= Delivery time (template tag) =

Display a product's delivery-time estimate anywhere in your theme:

    kramar_get_delivery_time( int $product_id = 0 ): string

Returns the product's own delivery-time term, falling back to the shop-wide default set in the EU Product Compliance module (or an empty string when neither is set). Works even when the automatic front-end display is turned off. Pass `0` to use the current global `$product`.

    echo esc_html( kramar_get_delivery_time( $product->get_id() ) );

**`kramar_delivery_time_label`** — Filter the resolved label.

    apply_filters( 'kramar_delivery_time_label', string $label, int $product_id )

**`kramar_delivery_time_html`** — Filter the rendered `<span class="kramar-delivery-time">` markup (escape your own output).

    apply_filters( 'kramar_delivery_time_html', string $html, string $label, WC_Product $product )

== Screenshots ==

1. Dashboard — get-started checklist, module/compliance stat cards, and an action-required panel.
2. Modules — enable only what you need; every module is opt-in and grouped by area.
3. Invoice template builder — drag-and-drop blocks with a live, true-to-print PDF preview.
4. Email Manager — toggle WooCommerce emails, edit each one, or add your own custom transactional emails.
5. Checkout field editor — add, reorder, and edit billing, shipping, and additional checkout fields.

== Upgrade Notice ==

= 1.0.2 =
A broad correctness release. Fixes invalid credit notes and VAT breakdowns on invoices, a checkout consent gate that could be skipped, product option surcharges that grew on every page load, and around forty other defects found in a full audit.

= 1.0.1 =
Fixes duplicate customer emails on custom order statuses, order statuses that silently reverted to Pending, and a withdrawal verification email that ignored its own settings.

= 1.0.0 =
Initial release.

== Changelog ==

= 1.0.2 =

Invoices and VAT

* Fixed: refunding an order by typing an amount, without selecting line items, produced a credit note with a subtotal of zero, no VAT line and a total that reconciled with nothing — not a valid corrective invoice. The net and VAT are now derived from the rate the order was taxed at, and the document describes what was credited.
* Fixed: an invoice could print a subtotal and a VAT base that disagreed by a cent or two ("Subtotal 90,05" beside "VAT 21% over 90,00"). The VAT base is now built from the same figures the document prints.
* Fixed: a line taxed at a genuine 0% rate lost its taxable base, and on a mixed-rate order that base was added to another rate's row — printing a VAT line that did not multiply out. Zero-rated lines now carry their own base.
* Fixed: a fee on an order appeared nowhere on the invoice, while its amount was inside both the VAT base and the total — leaving a taxable base higher than the subtotal printed directly above it, with nothing to account for the difference. Fees are now listed by name.
* Fixed: a credit note's VAT base was worked back out of the tax instead of summed from the refunded lines, so it could sit a cent or two away from the subtotal printed above it. Credit notes now use the same calculation as invoices.
* Fixed: crediting a mixed-rate order dropped the zero-rated part of the refund from the VAT breakdown entirely, because WooCommerce records no rate against a refunded line that took no tax.
* Fixed: an amount-only refund against an order taxed at more than one rate printed a single averaged rate that does not exist, such as "VAT 17,07%". The credit is now split across the rates the order was actually taxed at.
* Fixed: the customer notification for a partial refund never carried the credit note, and no setting could turn it on. Partial refunds now follow the "Refunded order" attachment setting, as full refunds already did.
* Fixed: regenerating an invoice re-dated it to today. The issue date is now preserved, as the invoice number already was.
* Fixed: a "Ship to" heading was printed with nothing under it on any order without separate shipping details.
* Fixed: untaxed shipping was folded into the taxable base of a rate it was never charged at, so an invoice for a 20,00 item with 3,00 untaxed shipping read "VAT 21% over 23,00: 4,20" — a base and a tax that do not multiply out. Untaxed lines are now excluded from every rate's base.
* Fixed: intra-EU B2B invoices never showed the reverse-charge notice, because it was looked up under an order meta key nothing ever wrote. It now reads the key the VAT ID module records, and is only shown when the order genuinely carries no VAT.
* Fixed: on an order with more than one refund, only the newest credit note could be downloaded and a resent refund email attached the wrong one. Each refund now has its own button and its own attachment.

Checkout

* Fixed: required legal checkboxes could be skipped entirely by submitting the checkout without a hidden field the form normally includes, completing the order with no consent given or recorded. Whether consent is required is now decided on the server.
* Fixed: entering a valid EU VAT number did not refresh the totals, so the reverse-charge exemption often never reached the amount charged.
* Fixed: VAT number validation only checked a minimum length, accepting values like "AAAAAAAAAAAA" as valid. Each country's format is now checked properly.
* Fixed: importing a checkout-field file with an empty section silently deleted that section's entire configuration. Empty sections are now skipped and reported.
* Fixed: importing two fields with the same key created duplicates that made the builder edit the wrong one. Duplicates are now collapsed and reported.
* Fixed: multi-line textarea checkout fields lost their line breaks when the order was saved.
* Fixed: the delivery-date error showed a literal "%d" instead of the number of days.
* Changed: WooCommerce's own terms checkbox is hidden by default on new installs, so customers no longer see two near-identical terms boxes.

Orders and withdrawal

* Fixed: an order status deleted while orders still used it left those orders showing "Pending payment" on the order screen, so saving one silently downgraded it. The Order Statuses screen now warns, naming the statuses and how many orders hold them.
* Fixed: the 14-day withdrawal period started at the order date when a shop never marks orders Completed, giving customers a shorter window than the law allows. It now starts on completion, and a shop can supply its own delivery date with the kramar_withdrawal_period_start filter.
* Changed: approving a withdrawal no longer creates a refund. It sets the status and sends the emails; the refund itself goes through WooCommerce's own refund screen, which is what the module's "How it works" panel has always described and what handles part-refunds, restocking and refunding via the gateway. The old behaviour created a refund record and marked the order Refunded without ever calling the payment gateway, so the customer was told their money was on its way when nothing had been sent.
* Fixed: approving a withdrawal request stopped with a fatal error, so the approval never completed.
* Fixed: a decided withdrawal request could be decided again — a stale tab or a double-click could flip an approved request to rejected.
* Fixed: requesting more information about a withdrawal sent the customer two emails — WooCommerce's order-note notification as well as Kramar's own.
* Fixed: sequential order numbers could be read back stale, indefinitely on sites using a persistent object cache.

Products

* Fixed: product extra-option surcharges were added again on every totals recalculation, so an item's price climbed each time the cart or checkout was loaded.
* Fixed: a submitted product option that was not one of the configured choices was accepted and charged nothing, while the invented value was stored on the order.
* Fixed: the Omnibus "lowest price in the last 30 days" ignored previous sale prices, showing a higher figure than the price actually charged and overstating the next discount.
* Fixed: minimum and step quantity rules could contradict each other, making a product impossible to add to the cart at any quantity.
* Fixed: a sale with no recorded start date had its 30-day clock restarted on every check, so it was never flagged or expired.
* Fixed: rating breakdown percentages could add up to 99%.
* Added: a notice when the active theme renders products with blocks, where the review features cannot appear.

Email and marketing

* Fixed: the Raw HTML and Text blocks in the email builder had all their markup stripped when saved.
* Fixed: order totals used in a subject line or a Data Field block appeared as raw HTML instead of a price.
* Fixed: messages could carry two conflicting Reply-To headers.
* Fixed: kramar_send_email() reported success even when nothing was sent, and calling it twice in one request could send the second email with the previous order's details.
* Fixed: out-of-stock variations appeared in the Google product feed regardless of the "include out-of-stock" setting, and variation titles and colours used internal slugs instead of names.
* Fixed: an abandoned-cart reminder could be sent twice for the same cart.

Elsewhere

* Fixed: the WooCommerce admin cleanup removed every WooCommerce Admin feature, breaking the Analytics screens. It now removes only marketing and onboarding surfaces.
* Fixed: weight-based shipping rules that were meant to add several rates only ever produced the last one.
* Fixed: dequeuing WooCommerce scripts stopped with a fatal error on the front end when "Dequeue WooCommerce JS" was enabled.
* Fixed: "Remove WooCommerce Blocks CSS" removed only the base stylesheet and one handle that no longer exists, leaving every per-block file — over 100KB on a shop page — still loading. It now removes them all, on the front end only.
* Fixed: deactivating or uninstalling left the Google Shopping Feed's scheduled task behind, firing forever at a hook nothing listens to.
* Fixed: a Dutch customer of a Dutch shop could remove 21% VAT from their own order by typing any format-valid VAT number for another EU country. The number's country must now match the address the goods are going to.
* Fixed: products with a quantity step greater than 1 could not be bought at all on a block theme — the quantity selector offered only quantities the server rejected.
* Fixed: setting a maximum quantity below the minimum made a product impossible to buy, with no quantity the shopper could choose to get out of it.
* Fixed: a product option list containing an empty entry charged a different surcharge than the product page advertised.
* Fixed: a product option named "0" never appeared on the product page at all and could not be chosen.
* Fixed: a repeated product option offered the same choice twice at two different prices, and always charged the first one's.
* Fixed: a quantity step set without a minimum let a shopper add 1 and be charged for a full step — 4 units at 4x the price, with no notice anywhere.
* Fixed: a quantity step larger than the maximum quantity made a product impossible to buy, showing a minimum and a maximum that contradicted each other.
* Fixed: a sale running unchanged for more than thirty days showed the regular price as its "lowest price in the last 30 days", higher than the price being charged that moment. The price currently on offer is now always considered.
* Fixed: a sale scheduled for a future date was recorded as though it had already been charged, so cancelling it before it ran still left it as the "lowest price in the last 30 days" for a month. Scheduled sales are recorded when they actually begin.
* Fixed: the reverse-charge exemption stuck to the country a VAT number was first validated against — changing the country afterwards left the order untaxed, the same domestic zero-rating the country check exists to prevent.
* Fixed: a checkout field added by hand-editing an import file was never shown and could not be deleted.
* Fixed: importing a checkout field named after one Kramar itself adds, such as the VAT number field, silently discarded it.
* Fixed: subject lines on back-in-stock, abandoned-cart and confirmation emails showed merge fields as literal text.
* Fixed: the PhotoSwipe toggle removed the lightbox's styling but not the lightbox.
* Fixed: the back-in-stock unsubscribe page could be created repeatedly, as the tracking and withdrawal pages once could.
* Fixed: if the request creating one of those pages died part-way, the page could never be created again. Fixed also: a configured page that had been moved to the trash was treated as still in use, so customer links kept pointing at it.
* Fixed: an invoice printed the order's main VAT rate against a zero-rated line, contradicting its own totals.
* Fixed: a product whose tax class has no rate for the customer's country was shown on the invoice with the order's main VAT rate and an amount of VAT that was never charged.
* Fixed: an amount-only refund on an order with several VAT rates printed rows whose tax did not match their stated base.
* Fixed: the delivery-date field rendered as a plain text box rather than a date picker, and its earliest-date limit did nothing.
* Fixed: only the configured order-tracking page was kept out of search results. Sites left with duplicate tracking pages by an earlier bug had the rest indexable.
* Fixed: the withdrawal page could be created repeatedly, the same way the tracking page once was.
* Fixed: subject lines on password-reset and new-account emails showed merge fields as literal text such as "{customer_first_name}".
* Fixed: the subject written in the builder was ignored for back-in-stock, abandoned-cart, review-reminder and withdrawal emails — the send always used the default.
* Fixed: Heading and Notice blocks showed HTML tags to the customer when they contained a price or address merge field.
* Fixed: the email builder canvas showed "{email_heading}" instead of the heading, while the preview and the sent email were correct.
* Fixed: the Google Shopping feed sent tax-inclusive prices to shops that display prices excluding tax, disagreeing with the landing page by the full VAT rate.
* Fixed: IP anonymisation wrote values that were not addresses when handed anything unusual, and the geolocation cleanup left deleted entries in the object cache.
* Fixed: order tracking failed when the customer typed the order number with a leading "#", as it is shown to them.
* Fixed: the reviews module warned that its features would not appear on themes where they do appear.
* Fixed: importing checkout fields reported success even when the file contained nothing usable.
* Fixed: repeated creation of duplicate "Track your order" pages caused by concurrent writes to the shared settings option.
* Fixed: IP anonymisation produced invalid addresses for most IPv6 visitors.
* Fixed: uninstalling with full cleanup left two database tables, several options and all scheduled tasks behind; deactivating left scheduled tasks running.
* Changed: the dashboard's EU compliance checklist no longer passes "Legal checkboxes on checkout" while an enabled checkbox points at a page that hasn't been set — the link renders as plain text at checkout, and the right of withdrawal has to be reachable there.
* Fixed: the developer reference documented function and filter names from before the plugin was renamed, none of which exist.

= 1.0.1 =
* Fixed: custom order status notifications were sent to the customer twice. The status module sent its own copy alongside the one delivered by the Email Manager; only the Email Manager copy is sent now, so From name, BCC and the email template all apply correctly.
* Fixed: a custom order status whose slug was longer than 17 characters could not be applied to an order — on databases without strict mode the status was silently truncated and the order fell back to Pending, with no error anywhere. Slug length is now validated when the status is saved, and any existing over-long status is flagged on the Order Statuses screen.
* Added: custom order statuses now have their own Slug field, so a long, readable status name (for example "Binnen 5 werkdagen") can be paired with a short slug. Leave it blank to generate one from the name.
* Fixed: clearing "Sends email" on a custom order status now stops its notification instead of leaving it active.
* Fixed: renaming a custom order status left its auto-generated notification email showing the old status name in its title, subject, and heading, with no way to correct it. Renaming a status now updates the email's name, subject, and description to match (any custom subject set in the Email Builder is preserved).
* Added: the legal checkbox settings now warn when an enabled checkbox uses a {..._link} placeholder with no page selected — the placeholder would otherwise render as plain, unclickable text at checkout.
* Fixed: the withdrawal verification email ignored its own on/off switch on the Emails screen and kept sending, ignored the subject set in the Email Builder, and was delivered without the configured Reply-To, CC or BCC. It is now sent the same way as every other Kramar email.
* Fixed: the default withdrawal verification template rendered its two body paragraphs as empty text and its button pointed at a dead link, so customers received a bare button they could not use. Existing templates are repaired automatically.
* Fixed: creating a custom email with a name that differed only in capitalisation or punctuation from an existing one silently replaced it, leaving the old template attached to the new email. The name is now rejected with an explanation, and errors from this dialog are shown instead of being swallowed.
* Fixed: custom order status slugs can no longer collide with the statuses used by the Refund & Returns module.
* Fixed: deleting a custom order status left its notification email active in the Email Manager.
* Fixed: the guest order-tracking email was sent without a From or Reply-To address, so it came from WordPress rather than the shop and was liable to be rejected or filtered as spam.
* Fixed (Dutch): the default privacy consent label used the wrong article ("Ik heb het …" instead of "Ik heb de …"). Note that this label is stored in your settings the first time it is saved, so an existing site needs the wording corrected under Kramar &rarr; Modules &rarr; Legal Checkboxes.
* Fixed: saving Business Details or the Email Sender defaults on the Settings page silently failed (returned "0") when the Email Manager module was disabled. Their save handlers are now registered independently of the module's enabled state.

= 1.0.0 =
* Initial release.
