Tjommi accepted a receipt, watched the purchased products for lower prices, and helped the customer claim the difference from the retailer.
The 2019 version of the website described the flow as three steps. Photograph, upload, or forward a receipt. Let Tjommi track the prices. Approve a claim when we found a lower price. By the 2021 international version, email ingestion and automated store communication had become central to the pitch.
The backend converted emails, PDFs, screenshots, product pages, tracking responses, customer-support replies, and retailer policies into a small set of records used to track prices and submit claims. This article documents its data model, module boundaries, inbox and document ingestion, retailer integrations, price-event generation, claim workflows, and the later use of completion models for receipt formats without handcrafted parsers.
The implementation discussed here spans several generations of the product. Tjommi's public policies changed over time. The advertised fee moved from 20% in the 2019 material to 25% in the 2021 material, and the tracking windows also changed. The implementation should not be read as one frozen set of commercial terms.
Architecture timeline
The repository spans late 2019 through September 2023. The overlapping scanner, OCR, and AI paths were built at different times to handle different failures. They were not designed as one complete document-processing system from the beginning.
- December 2019: Receipt CRUD landed first. The initial Komplett and Power PDF scanners followed two days later.
- April to May 2020: Gmail messages were normalized into a scanner-friendly shape, then the first Power and Elkjøp email scanners combined inbox parsing with PDF attachments. Mobile uploads moved to direct S3 uploads so receipt files no longer passed through the application server's 6 MB request limit.
- December 2020: Repeat inbox scans became safe. Receipts were deduplicated by source ID and receipt items by receipt and product identity.
- June 2021:
ProductFinderand the scraping-module refactor brought product lookup, search engines, scraping, and store configuration behind shared interfaces. - November 2021: AWS Textract templates and the receipt annotator were introduced for difficult document attachments. The older attachment PDF scanner was disabled when the new path shipped.
- December 2021: TicketAutomation was released, then extended with JYSK and XXL rules. Each merchant could attach matching predicates and actions to its store definition.
- June to December 2022: Receipt parsing with OpenAI began as a
text-davinci-002experiment. It gained prompts, DTOs, and validation, and uploaded receipts switched from Textract to the AI path after it proved more reliable. The default completion model later changed totext-davinci-003, and one legacy merchant PDF parser was removed in favor of AI parsing. - April to May 2023: The AI path moved to
gpt-3.5-turboand became a scheduled backlog with scan logs, retry-on-rate-limit behavior, a second prompt version, improved region detection, and hidden autogenerated merchant records for previously unknown receipt formats.
System boundaries and data model
The backend handled evidence with different levels of trust.
An HTML order confirmation was easy to retain but could be malformed. A PDF might contain perfect embedded text, a scrambled character stream, or only an image. OCR could recover words without recovering the table they came from. A retailer search result could identify the product but say nothing about why it matched the receipt. A scraped price was only useful if it belonged to the same product, in the same market, during the relevant guarantee window. A support email saying "we have credited the amount" was only safe to automate if it belonged to the right order, line item, and claim.
The system separated ingestion, product matching, price observations, and claims through these persisted models:
| Boundary | What it meant |
|---|---|
StoredEmail | A provider message had been deduplicated and was now queryable by the rest of the application. |
Receipt | Some extractor had produced a purchase with order metadata, totals, and provenance. |
ReceiptItem | A purchased line had a price and, ideally, a resolved product identity. |
Product | A merchant-specific SKU/EAN/URL could accumulate price observations. |
Scrape | The system observed a price at a specific time. It did not overwrite history. |
PriceEvent | A new observation passed the eligibility and value gates for a purchased item. |
Ticket | A claim became a stateful business process with messages, actions, and an outcome. |
Every extraction path ended at the same models. The Gmail scanner, an uploaded PDF, a store-specific parser, AWS Textract, and OpenAI did not get their own downstream product. They all had to produce a receipt that the existing product-resolution, scraping, comparison, ticket, and reporting code understood.
Database schema and relationships
The conceptual pipeline above was backed by an ordinary relational schema. The diagram below deliberately omits timestamps, UI fields, notification preferences, support-address tables, experiments, and most payment bookkeeping. It keeps the columns that explain identity, provenance, and the transition from evidence to a claim.
Dashed connectors mark relationships expressed by model code rather than an enforced database constraint. The region on
a domain and the user on a receipt were straightforward references even though this schema snapshot did not constrain
them. Two other dashed joins are more architecturally interesting. A Receipt recorded its source as a type plus
source_id; for an HTML email, that source ID was the provider's message_id, not a foreign key to stored_emails.id.
That let the receipt provenance model cover email, PDF, uploaded assets, and older sources, but referential integrity
for that join lived in application code.
Likewise, historical Scrape rows did not contain product_id. A price observation belonged to a product through the
pair (domain_id, sku). Product::scrapes() and Scrape::product() therefore used a composite relationship. URLs and
names could change while a merchant's own product number remained the identity carried through price history.
The rest of the schema shows why normalization mattered:
- one stored email could retain attachments, enter the pending AI queue, create parcel records, or participate in a ticket-automation event;
- one receipt contained independently resolvable line items, each optionally connected to a canonical product and its best observed scrape;
- a
PriceEventjoined the purchased line to the exact lower-price observation that justified it; - a
Ticketcarried the stateful claim around that event, whileautomation_eventsmade a matching(ticket, stored email, step)execute once; - parcels formed a parallel branch from the same inbox evidence and accumulated their own normalized event history.
Some downstream tables repeat user_id or domain_id even when those values were reachable through another relation.
That denormalization made ownership and merchant-scoped queries direct, while the more specific foreign keys preserved
the evidence chain. Large email bodies, receipt files, and OCR inputs could live in object storage; the relational rows
kept their pointers and queryable metadata.
AI extraction produced the same canonical receipt as the existing parsers, so the product-matching, price-tracking, and claim code did not change when it was introduced.
Laravel backend architecture
The backend was a Laravel application with MySQL, Redis queues, Laravel Horizon, and object storage for bulky assets. The contemporary technical stack description also names PHP 8.1, AWS EC2, Redis, Horizon, React, and React Native.
The application remained a single deployable service. MySQL records connected the processing stages, and Redis queues handled work that was slow, rate-limited, or unsuitable for an HTTP request. Kafka, Kubernetes, and separately deployed services were unnecessary at this scale.
The modular monolith gave us the semantics we needed:
- MySQL was the source of truth for users, mail metadata, receipts, products, observations, claims, and workflow state.
- Redis and Horizon moved expensive work out of HTTP requests and separated scanning, scraping, comparison, and notification workloads.
- Object storage kept large receipt assets and HTML away from the hottest relational queries.
- Laravel pipelines gave email processing a chain-of-responsibility shape without introducing another runtime.
- Small module boundaries let extraction systems evolve independently while sharing the same models.
Retailer behavior accounted for most of the implementation complexity. Keeping it in one deployable application allowed a store definition to combine its receipt scanner, product scraper, search adapter, proxy policy, price-match settings, and automation rules. Queues and intermediate records were used where external failures, rate limits, or cost required them; local synchronous calls covered the remaining paths.
Repository and module organization
The repository used Laravel's conventional app/Models, app/Http, database, and routes directories, but most
domain behavior lived under app/Modules. A module was not an independently deployed service or a Composer package. It
was a directory-level ownership boundary inside the application, usually with its own service provider, contracts, data
objects, jobs, commands, and tests.
The parts relevant to the price-protection pipeline were organized roughly like this:
app/
├── Models/
│ ├── StoredEmail.php, Receipt.php, ReceiptItem.php
│ ├── Product.php, Scrape.php, PriceEvent.php, Ticket.php
│ └── Parcel.php, ParcelEvent.php
├── Modules/
│ ├── EmailScanner/
│ │ ├── Message/ # provider-neutral mail and attachment objects
│ │ ├── Services/ # Gmail, Outlook, and MailServiceFactory
│ │ ├── Processor/Steps/ # persistence, receipt, parcel, gift-card, ticket stages
│ │ └── Jobs/ # initial, incremental, targeted, and extraction jobs
│ ├── EcommerceStore/
│ │ ├── Discovery/ # sitemap, crawl, category, and Shopify URL sources
│ │ └── Logic/ # normalized product persistence
│ ├── Scraping/
│ │ ├── Fields/ # Field, Collection, Combination, Variation
│ │ ├── Strategies/ # Shopify, JSON-LD, schema, ads, dataLayer
│ │ ├── Jobs/ # direct and proxied scraping work
│ │ └── Proxy/ # ScrapingBee policy and request transport
│ ├── SearchEngines/ # generic and merchant-native SKU/name search adapters
│ ├── PdfScanner/ # embedded-text PDF parsers and receipt hydrators
│ ├── Textract/ # OCR client, block graph, template DSL
│ ├── OpenAI/ # prompts, DTOs, client, and parsing jobs
│ ├── Giftcard/ # voucher scanners, lifecycle events, and types
│ ├── TicketAutomation/ # Step interpreter, predicates, actions, ledger
│ ├── ParcelTracking/ # email scanners, carrier adapters, status DTOs
│ └── Reporting/ # operational reports and daily snapshots
├── Console/Kernel.php # recurring inbox, scraping, reporting, cleanup work
└── Http/ # API controllers, resources, and Livewire repair tools
stores/ # 2,379 executable retailer definitions
tests/
├── Scraping/ # captured product pages and extractor assertions
│ ├── EmailScanners/ # merchant email scanner tests
│ ├── Extractors/ # product-page extractor tests
│ └── ParcelTracking/ # carrier email scanner tests
└── Data/ # captured email, page, PDF, Textract, and carrier inputs
The Eloquent models were the shared persistence boundary. Modules collaborated through those models and a few explicit
contracts: MailService, scraping Strategy, ParcelScanner, Tracker, and the TicketAutomation predicate/action
interfaces. A queued job could still resolve a factory, query a model, and dispatch another job. Provider-specific
payloads were converted inside the module that owned the integration.
For example, Gmail JSON became MailMessage before it entered the processor. A Bring response became
TrackingInformation before events were written. Textract's block graph became a template result before receipt models
were created. OpenAI JSON became ReceiptV1 before it reached the ordinary receipt path. That kept vendor schemas out
of models and controllers even though everything ran in the same process.
stores/ was the deliberate exception to a purely module-shaped tree. A retailer integration cut across scraping,
search, receipt parsing, commercial policy, and sometimes ticket automation, so all of that retailer-specific wiring
lived in one executable definition. The reusable engines stayed under app/Modules; the per-retailer choices stayed in
stores/.
Inbox ingestion and message processing
Tjommi connected to Gmail and Outlook. Both providers were adapted into the same internal MailService and
MailMessage shapes, so the processing code did not have to know which API supplied a message.
The shared message object kept normalized content and provider identifiers together:
// app/Modules/EmailScanner/Message/MailMessage.php
class MailMessage
{
protected DateTime $receivedAt;
protected ?string $fromEmail = null;
protected ?string $toEmail = null;
protected ?string $replyTo = null;
protected ?string $subject = null;
protected ?string $bodyHtml = null;
protected ?string $bodyText = null;
protected ?array $attachments = [];
// Provider API IDs and the RFC 2822 Message-ID remain separate.
protected ?string $realMessageId = null;
protected ?string $messageId = null;
protected ?string $threadId = null;
protected string $provider;
// ... fluent setters, normalized-address helpers, body conversion,
// and lazy attachment access
}
Gmail and Outlook adapters populated this object before processing began. Receipt scanners and pipeline steps could therefore use the same sender, subject, body, attachment, message, and thread fields for both providers.
The scan path had two modes. An initial connection looked backwards over roughly three months. Incremental scans used the last received timestamp. Gmail messages were fetched in batches, capped, sorted chronologically, and then processed oldest first. Ordering mattered because a later customer-support message could depend on metadata learned from an earlier acknowledgement.
The top-level job handled provider iteration and passed each normalized message to the processor:
// app/Modules/EmailScanner/Jobs/ScanInboxForEverything.php
class ScanInboxForEverything implements ShouldQueue
{
// ... constructor selects scanning-high and stores token + cutoff date
public function handle(MessageProcessor $processor): void
{
$service = MailServiceFactory::makeFromToken($this->mailAuthToken);
$messages = $service->getMessagesIterator(new Query(
afterDate: $this->afterDate,
));
foreach ($messages as $message) {
$processor->process(new Context(
$this->mailAuthToken,
$message,
));
}
// ... update last-scanned time and emit the new-receipts event
}
}
MessageProcessor passed each normalized message through a Laravel pipeline:
// app/Modules/EmailScanner/Processor/MessageProcessor.php
class MessageProcessor
{
// ... the constructor receives Laravel's Pipeline and loads defaultSteps()
public function process(Context $context): void
{
$this->pipeline
->send($context)
->through($this->steps)
->via("beforeHandle")
->thenReturn();
}
public function defaultSteps(): array
{
return [
Steps\UpdateLastReceivedAt::class,
Steps\IgnoreBlacklistedEmails::class,
Steps\StoreEmailAndAttachments::class,
Steps\FindParcels::class,
Steps\FindReceipts::class,
Steps\FindUsedGiftcards::class,
Steps\FindGiftcards::class,
Steps\FindAndTagTicketReplies::class,
];
}
// ... smaller parcel-only and gift-card-only step lists
}
The list changed as the product changed. Ticket automation was once inserted after reply tagging and was later removed; tagging and other specialist passes also appeared in alternate paths. Regardless of the list, each stage could reject, persist, classify, or enrich the same context.
Each stage inherited a wrapper that caught failures, wrote an activity log with the stage name and message identity, then allowed the rest of the inbox scan to continue. One broken merchant email should not abort hundreds of unrelated messages.
Email persistence and deduplication
The first filter was broad but not empty. It excluded known irrelevant senders and newsletters, while retaining messages connected to a known scanner, merchant, support address, ticket thread, receipt vocabulary, or plausible attachment. A PDF was a particularly strong "future us may regret deleting this" signal.
StoreEmailAndAttachments then deduplicated on provider message ID, user, and mail token. The relational record kept
provider IDs, thread IDs, sender, host, subject, timestamps, text, headers, and storage pointers. Attachment records
kept their metadata and relationship to the message; content could be fetched lazily from the mail provider when a later
stage actually needed it.
Persisting before final classification was deliberate. An email could become useful in several independent ways:
- a known order confirmation could create a receipt;
- a shipping update could create or enrich a parcel;
- a gift-card email could become compensation from a retailer;
- a later message could reveal that a gift card had been used;
- a support reply could advance a price-claim ticket;
- an unknown receipt-like email could wait for scarce AI capacity;
- the corpus could reveal a new stable merchant pattern we had not encoded yet.
In practice, StoredEmail became a durable event log for inbox-derived work.
Scanner routing and the AI backlog
Known merchants did not need an LLM. FindReceipts asked the deterministic scanner registry first and dispatched the
existing parser asynchronously:
// app/Modules/EmailScanner/Processor/Steps/FindReceipts.php
class FindReceipts extends Step
{
public function handle(Context $context, Closure $next)
{
// ... require a persisted StoredEmail before dispatching work
if (EmailScannerFactory::forReceipts($context->getMailMessage())) {
CreateReceiptFromStoredEmail::dispatch(
storedEmail: $context->getStoredEmail(),
forceRescan: $context->shouldForceRescan(),
);
return $next($context);
}
// ... otherwise evaluate the long-tail receipt heuristic
}
}
Only an unknown message reached the heuristic candidate test. The code excluded known false positives, checked sender and subject blacklists, then searched the subject, simplified HTML, and plaintext for receipt vocabulary.
Passing that test still did not call OpenAI. It created a durable PendingReceiptScan with a priority derived from age:
// app/Modules/EmailScanner/Processor/Steps/FindReceipts.php
class FindReceipts extends Step
{
public function handle(Context $context, Closure $next)
{
// ... known scanner path shown above
if ($this->messageIsPotentialReceipt($context->getMailMessage(), $context->getToken())) {
$storedEmail = $context->getStoredEmail();
$priority = match (true) {
$storedEmail->received_at->isAfter(now()->subDays(14)) => 1,
$storedEmail->received_at->isAfter(now()->subDays(90)) => 2,
default => 3,
};
PendingReceiptScan::updateOrCreate(
["stored_email_id" => $storedEmail->id],
["openai_account" => 0, "priority" => $priority],
);
}
return $next($context);
}
// ... sender/subject blacklists and receipt-vocabulary matching
}
A separate scheduler drained bounded batches, allocated an OpenAI account, and dispatched the AI job. A 429 returned
the candidate to the pool; terminal outcomes removed it. The final repository snapshot contains this scheduler but has
its periodic invocation commented out, so it is better read as an implemented operating path from that period than as a
claim about the last deployment configuration.
The resulting order was: exact parser for known formats, a cheap heuristic for unknown messages, a durable priority queue, rate-limited semantic extraction, and human verification for incomplete results.
Gift-card discovery and claim outcomes
Gift cards were another thing the inbox could discover. A voucher email had a sender, issuer, code, amount, currency, and sometimes an expiry date. That was enough to retain it as an item in the user's account rather than leave it as an email that happened to contain a code.
The scanner contract was smaller than the receipt contract. A store-specific scanner identified the voucher and extracted its fields. Shared code attached it to the user and source email, deduplicated it, and emitted an event when a usable voucher first appeared:
// app/Modules/Giftcard/GiftcardEmailScanner.php
abstract class GiftcardEmailScanner extends BaseEmailScanner
{
abstract public function issuer(): string;
abstract public function code(): string;
abstract public function amount(): float;
// ... expiry, validity period, currency, and type hooks
public function createGiftcardForUser(User $user): ?Giftcard
{
$giftcard = Giftcard::query()->updateOrCreate([
"user_id" => $user->id,
"issuer" => $this->issuer(),
"code" => $this->code(),
], [
"amount" => $this->amount(),
"currency" => $this->currency(),
"expires_at" => $this->expiresAt(),
"received_at" => $this->message->getReceivedAt(),
"type" => $this->type(),
"stored_email_id" => StoredEmail::findByMessageId(
$this->message->getMessageId(),
)?->id,
]);
if ($giftcard->wasRecentlyCreated && $giftcard->isNotExpired()) {
event(new GiftcardFound($giftcard));
}
return $giftcard;
}
}
For example, KomplettGiftcardScanner used the same normalized-text operations as the receipt and PDF parsers. It took
the code between GAVEKORTKODE: and GYLDIG, the amount between BELØP: and GAVEKORTKODE, and converted the printed
expiry date. The merchant implementation decoded the document; the shared model handled identity and lifecycle.
A later inbox scan could also infer that a voucher had been spent. The rule looked at unused vouchers that predated the new email, then searched that email's HTML for a known code:
// app/Modules/EmailScanner/Processor/Steps/FindUsedGiftcards.php
class FindUsedGiftcards extends Step
{
public function handle(Context $context, Closure $next)
{
// ... skip Tjommi mail and messages that were not persisted
$storedEmail = $context->getStoredEmail();
$unusedGiftcards = $context->getToken()->user->giftcards()
->where("received_at", "<", $storedEmail->received_at)
->whereNull("used_at")
->get();
foreach ($unusedGiftcards as $giftcard) {
if (! trim($giftcard->code)) continue;
if (Str::contains($storedEmail->body_html, $giftcard->code, ignoreCase: true)) {
$giftcard->markUsedIn($storedEmail);
break; // One order was assumed to spend at most one voucher.
}
}
return $next($context);
}
}
This was evidence-based bookkeeping, not a redemption API. The implementation noted that a generic code could produce a
false positive. Recording used_in_stored_email_id kept the email that caused the change available for review.
Gift cards also appeared at the other end of a price-match claim. The internal ticket screen could record a code
provided by the retailer or assign an unused voucher from partner inventory. The resulting Giftcard linked the user,
ticket, issuer domain, amount, code, relevant dates, and type. The action also updated the refunded amount, changed the
ticket state, and emitted the event used by the notification layer.
JYSK's TicketAutomation path handled another version of the same outcome. It could recognize an attached refund PDF, parse the voucher, check its amount against the expected refund, and create the same model. The model therefore covered both vouchers discovered in ordinary email and vouchers received as the outcome of a claim. Expiry handling, notifications, display, and later use detection did not need separate implementations for those paths.
Parcel discovery and shipping-status tracking
Receipt collection was not the only useful structure in the inbox. Shipping notifications contained tracking numbers,
pickup codes, carrier names, service-point addresses, and status changes. Tjommi parsed those messages into a separate
Parcel/ParcelEvent timeline that the mobile API could expose without leaking carrier-specific response formats.
Parcel discovery had two paths:
FindParcelsselected a carrier-specific email scanner by sender and message shape. The snapshot contains scanners for Posten/Bring, PostNord, DHL, HeltHjem, DAO, GLS, and Danske Fragtmænd.ExtractTrackingCodessearched arbitrary retailer email HTML with a registry of tracking-URL and code patterns. It rejected messages with more than five matches as suspicious, ignored stale DHL candidates, and logged the regex that produced each candidate.
The ordinary inbox pipeline used the first path. A separate parcel-only scan could query known carrier senders in chunks
of five and run the smaller StoreEmailAndAttachments → FindParcels → ExtractTrackingCodes pipeline. That targeted mode
was useful for scanning historical mail without running every receipt, gift-card, and ticket stage again.
Carrier email scanners
ParcelScannerFactory tried the known scanners in order. A scanner owned two different responsibilities: recognize a
message from one carrier, then extract whatever could be known without calling the carrier API. For a Posten email that
could be the tracking number and a four-character pickup code. Other scanners also extracted optional sender, recipient,
or service-point fields.
BaseParcelScanner then performed the shared persistence work:
// app/Modules/ParcelTracking/BaseParcelScanner.php
abstract class BaseParcelScanner implements ParcelScanner
{
// ... carrier-specific trackingCode(), pickupCode(), and courier()
public function storeParcelInformation(User $user): ?Parcel
{
$trackingCode = $this->trackingCode();
if (! $trackingCode) return null;
$parcel = Parcel::query()->updateOrCreate(
[
"user_id" => $user->id,
"tracking_code" => $trackingCode,
"courier" => $this->courier(),
],
[
"tracking_url" => TrackerFactory::make($this->courier())
->trackingUrl($trackingCode),
],
);
// ... merge optional sender, recipient, and pickup metadata
if ($this->pickupCode() && $parcel->pickup_code === null) {
$parcel->update(["pickup_code" => $this->pickupCode()]);
$parcel->parcelEvents()->create([
"date" => $this->message->getReceivedAt(),
"status" => Status::PICKUP_CODE_RECEIVED,
"description" => "Pickup code retrieved from email",
// ... retain source-message metadata
]);
}
SyncParcelEvents::dispatch($parcel);
return $parcel;
}
}
The identity key was (user_id, tracking_code, courier). Receiving three notifications about the same shipment enriched
one parcel rather than creating three. A pickup code extracted from email became a local event immediately; the external
tracking sync did not have to know how the code arrived.
Carrier adapters and normalized status events
TrackerFactory mapped the internal Courier enum to seven adapters. Posten and Bring intentionally shared the Bring
adapter. Each adapter called a different API or, for carriers without a suitable API, parsed its tracking endpoint. They
all returned the same TrackingInformation object:
- the tracking code and courier;
- raw provider response for later debugging;
- normalized tracking events;
- optional parcel dimensions and product name;
- optional sender and recipient;
- optional pickup-point name, address, map URL, and coordinates.
The DTO retained both normalized fields and the carrier's original payload:
// app/Modules/ParcelTracking/Data/TrackingInformation.php
class TrackingInformation extends FlexibleDataTransferObject
{
public $trackingCode;
public $courier;
public $parcelDetail; // Product name and dimensions.
public $recipient;
public $sender;
public $pickup; // Address, URL, and coordinates.
public $events; // TrackingEvent[].
public $raw; // Untouched carrier response.
}
// app/Modules/ParcelTracking/Data/TrackingEvent.php
class TrackingEvent extends DataTransferObject
{
public $date;
public $status; // Internal Status enum.
public ?TrackingEventLocation $location;
public $description;
public $raw; // Original carrier event.
}
Carrier adapters could preserve extra evidence in raw while the synchronization job used the common status, date,
description, and location fields.
The Bring adapter shows where provider-specific semantics stopped:
// app/Modules/ParcelTracking/Trackers/Bring.php
class Bring implements Tracker
{
public function track(string $trackingCode): ?TrackingInformation
{
$data = Http::timeout(10)
->withHeaders($this->headers())
->get("https://api.bring.com/tracking/api/v2/tracking.json", [
"q" => $trackingCode,
])
->json();
// ... validate the response and resolve pickup-point details
return new TrackingInformation([
"raw" => $data,
"trackingCode" => $trackingCode,
"courier" => Courier::bring,
"events" => collect($package["eventSet"])->map(
fn ($event) => new TrackingEvent([
"date" => Carbon::parse($event["dateIso"]),
"status" => $this->mapStatus($event["status"]),
"location" => $this->mapLocation($event),
"description" => strip_tags($event["description"] ?? ""),
"raw" => $event,
]),
),
// ... parcel details, sender, recipient, and pickup point
]);
}
protected function mapStatus(string $status): Status
{
return match ($status) {
"DELIVERED" => Status::DELIVERED,
"DELIVERED_SENDER" => Status::RETURNED,
"DEVIATION" => Status::DEVIATION,
"NOTIFICATION_SENT" => Status::NOTIFICATION,
"RETURN", "IN_TRANSIT", "HANDED_IN",
"TRANSPORT_TO_RECIPIENT" => Status::IN_TRANSIT,
"READY_FOR_PICKUP" => Status::PICKUP,
default => Status::UNKNOWN,
};
}
}
Every carrier had its own vocabulary. The internal enum reduced it to IN_TRANSIT, PICKUP, DELIVERED, RETURNED,
DEVIATION, NOTIFICATION, and UNKNOWN, plus the email-derived PICKUP_CODE_RECEIVED. The raw provider event stayed
attached, so normalization did not destroy the evidence needed to debug an incorrect mapping.
Status synchronization, deduplication, and false-positive cleanup
SyncParcelEvents ran on the low-priority scanning queue. It updated parcel-level details, stored the latest raw
carrier response, marked a successful parcel as verified, and upserted each event by parcel and provider timestamp:
// app/Modules/ParcelTracking/Jobs/SyncParcelEvents.php
class SyncParcelEvents implements ShouldQueue
{
public function handle(): void
{
$tracking = TrackerFactory::forParcel($this->parcel)
->track($this->parcel->tracking_code);
// ... fill dimensions, sender, recipient, pickup point, and raw response
foreach ($tracking->events as $event) {
ParcelEvent::query()->updateOrCreate(
[
"parcel_id" => $this->parcel->id,
"date" => $event->date,
],
[
"meta" => $event->raw,
"status" => $event->status,
"description" => $event->description,
"location_name" => $event->location->name ?? null,
"location_city" => $event->location->city ?? null,
// ... zip and country
],
);
}
}
// ... a carrier rate limit releases the job for ten minutes;
// an unverified candidate with no real event history is deleted on not-found
}
The cleanup behavior mattered because the generic regex path could produce a plausible-looking but invalid tracking number. A failed first verification removed an unverified parcel with no meaningful event history. A rate limit did not: it released the job for ten minutes and retained the candidate.
The model exposed first and latest events and derived delivery/return state from the normalized history. A later return
event overrode an earlier delivered event. The mobile endpoint returned only verified parcels, enforced ownership
through ParcelPolicy, and serialized events in reverse chronological order.
There were also event classes for shipped, ready-for-pickup, and delivered notifications. In the final snapshot, the
code that emitted them from the sync job was commented out with a warning about producing thousands of events. The
tracking timeline was implemented and tested; automatic notification fan-out should be treated as an unfinished path,
not as a live feature. A parcel:sync command could requeue parcels created within the previous two weeks, but the
final Console\Kernel did not schedule that command, so the snapshot only proves immediate synchronization on parcel
discovery, not continuous polling in the last deployment configuration.
Receipt source formats and scanner coverage
"Email receipt" covered at least four materially different inputs:
- structured HTML whose important values had stable selectors;
- HTML that could only be understood after aggressive text normalization;
- an email body that merely pointed to or attached a PDF;
- an image or scanned PDF that had no usable text layer.
For a known merchant, an EmailScanner encoded sender predicates and extraction logic. Some were simple regex and
DomCrawler code. Others found a link, fetched a second document, parsed a PDF, or resolved a line item through the
merchant's product search. The public description from a
2021 ecommerce interview matches the code:
generic templates where possible, custom scrapers where necessary, and combinations of regex, text splitting, HTML
scraping, and OCR.
The repository eventually contained 679 merchant email-scanner classes and more than 2,000 captured email fixtures. Reusable extraction primitives covered repeated formats, while PHP callbacks handled merchant-specific cases that did not fit the shared operations.
The canonical line-item shape remained small: name, quantity, unit price, total, SKU, and sometimes URL. A scanner did not have to solve price tracking. It only had to give the rest of Tjommi enough identity to try.
The registry did not retain hundreds of scanner objects. EmailScannerFactory iterated class names and called each
scanner's static canScan() until one matched. That method instantiated the candidate long enough to evaluate a small
predicate table, normally sender plus subject position, while supporting forwarded messages by looking for the original
sender inside the body. The first matching scanner received the provider-neutral MailMessage:
// app/Modules/EmailScanner/EmailScannerFactory.php
class EmailScannerFactory
{
public static function forReceipts(MailMessage $message): ?ReceiptEmailScanner
{
foreach (self::scanners() as $scannerClass) {
if ($scannerClass::canScan($message)) {
return new $scannerClass($message);
}
}
return null;
}
public static function scanners(): array
{
return [
Scanners\AdlibrisEmailScanner::class,
Scanners\BooztEmailScanner::class,
Scanners\KomplettEmailScanner::class,
// ... hundreds of merchant scanners
];
}
}
BaseEmailScanner memoized a Symfony Crawler, detected and converted non-UTF-8 input, exposed normalized text and
subject helpers, and contained utilities for repeated DOM traversal. ReceiptEmailScanner added the actual receipt
contract: domain(), orderReference(), totalAmount(), and lineItems(), with optional overrides for tax, shipping,
discount, currency, purchase time, and payment method.
Creating the receipt was shared code, not reimplemented by every merchant. It deduplicated completed receipts by user,
order reference, and domain; upserted the canonical receipt by provider message ID; stored the original HTML asset;
resolved every LineItem through ProductFinder; and dispatched an immediate price comparison for newly created
receipt items. The receipt moved from PENDING to COMPLETED only when at least one product-backed item existed.
That contract also explains why a scanner could be narrow. A merchant class only decoded its document. Shared code owned deduplication, persistence, product resolution, state changes, and comparison jobs.
Captured evidence made these integrations maintainable. Scanner tests replayed saved email HTML, attachments, PDFs, and
expected values instead of depending on a live mailbox. Across the wider repository, tests/Data held more than 7,000
third-party artifacts at the captured revision, including roughly 2,000 email samples, 77 PDFs, 70 Textract responses,
and almost 5,000 product pages. The fixtures were not incidental test data; they were versioned samples of the external
formats the parsers claimed to understand.
Elgiganten HTML receipt scanner
Elgiganten's Danish confirmation email is a useful example because it was neither a generic template nor an unstructured
wall of text. The email had a stable local grammar hidden inside deeply nested presentation tables. A product began at
a cell with rowspan="4". Two ancestor levels up was the table containing all of the product rows. Two rows after each
product heading was a row containing Antal, the total price, and the quantity.
The scanner encoded that grammar directly. It did not depend on generated CSS class names, and it did not pretend the whole document was a clean table:
// app/Modules/EmailScanner/Scanners/ElgigantenEmailScanner.php
class ElgigantenEmailScanner extends ReceiptEmailScanner
{
protected static $predicates = [
["from" => "donotreply@elgiganten.dk", "subject" => "Ordrebekræftelse"],
["from" => "donotreply@elgiganten.dk", "subject" => "Vi har modtaget din bestilling"],
["from" => "noreply@elgiganten.dk", "subject" => "Ordrebekræftelse"],
];
public function orderReference(): string
{
if ($this->subject()->startsWith("Ordrebekræftelse")) {
return $this->subject()->after("Ordrebekræftelse")->trim();
}
return $this->text()->after("Ordre #")->before(" ")->trim();
}
public function lineItems(): array
{
$itemCells = $this->dom()->filter("td[rowspan='4']");
$table = $this->ascendAncestors($itemCells, 2);
$items = $table->filter("tr")->each(function (Crawler $row, $index) use ($table) {
if (! $row->filter("td[rowspan='4']")->count() || $row->filter("td")->count() !== 3) {
return null;
}
$priceRow = $table->filter("tr")->eq($index + 2);
if (! Str::contains($priceRow->text(), "Antal")) {
return null;
}
$price = Str::of($priceRow->filter("td")->eq(1)->text())->trim()->asNumber();
$quantity = Str::of($priceRow->filter("td")->eq(2)->text())->trim()->asNumber();
return new LineItem([
"name" => Str::of($row->filter("td")->eq(2)->text())->trim()->toString(),
"totalPrice" => $price,
"quantity" => $quantity,
"unitPrice" => $price / $quantity,
]);
});
return collect($items)->filter()->values()->toArray();
}
public function totalAmount(): float
{
$rows = $this->dom()->filter("tr")->each(function (Crawler $row) {
if ($row->filter("td")->count() !== 3 || ! Str::contains($row->text(), "Totalsum:")) {
return 0;
}
return Str::of($row->filter("td")->eq(1)->text())->trim()->asNumber();
});
return array_sum($rows);
}
// shippingAmount() and taxAmount() repeat the summary-row walk
// with the labels "Fragt:" and "Moms".
}
There are two kinds of landmarks in that class. Sender and subject are routing landmarks: they decide whether this
scanner owns the message. rowspan="4", Antal, Totalsum:, Fragt:, and Moms are structural and semantic
landmarks: they locate values without relying on absolute positions in the entire document. The alternate order
reference branch shows the same principle at the text layer. The old template put the reference in the subject; the new
one put it after Ordre # in the body.
This structure was easier to maintain than one large regular expression. Each rejection condition described a valid product row, and summary extraction remained independent from line-item extraction. A merchant could add another table above the order without shifting hard-coded global row numbers.
Scanner fixture tests and legacy email formats
The workflow for a new scanner began with real messages, not invented HTML. The scanner:make command accepted stored
email IDs, copied each raw HTML or text body into tests/Data/Emails, generated the scanner class, and generated one
test method per captured email with TODO assertions. In practice we usually selected two to four representative
messages, manually wrote the expected order total and line items, and iterated on the parser until every case was green.
The Elgiganten test eventually grew to eleven fixtures. A representative case looks like this:
// tests/Scraping/EmailScanners/ElgigantenEmailScannerTest.php
class ElgigantenEmailScannerTest extends TestCase
{
/** @test */
public function can_extract_data_from_email_1()
{
$scanner = EmailScannerFactory::forReceipts(
(new MailMessage())
->setBodyHtml($this->loadEmailSample("ElgigantenOrderConfirmation1.html"))
->setReceivedAt(Carbon::parse("2022-12-08 21:05:31"))
->setFromEmail("donotreply@elgiganten.dk")
->setSubject("Ordrebekræftelse 2105338762")
);
$this->assertInstanceOf(ElgigantenEmailScanner::class, $scanner);
$this->assertEquals("2105338762", $scanner->orderReference());
$this->assertEquals(1027, $scanner->totalAmount());
$lineItems = $scanner->lineItems();
$this->assertCount(3, $lineItems);
$this->assertEquals("New Super Mario Bros. U Deluxe - Switch", $lineItems[0]->name);
$this->assertEquals(1, $lineItems[0]->quantity);
$this->assertEquals(399, $lineItems[0]->unitPrice);
$this->assertEquals(399, $lineItems[0]->totalPrice);
}
// ... ten more captured confirmations, with alternate subjects,
// shipping charges, quantities, tax, and later template versions
}
Other cases in the same file retained a May 2022 layout with three Logitech keyboard-and-mouse sets, a version with
AirPods plus shipping, and a June 2023 design whose subject changed to Vi har modtaget din bestilling and whose body
contained the order reference and VAT. Keeping all of them green made the parser backward compatible with email designs
the retailer no longer sent.
That backward compatibility mattered. The first inbox scan began at the start of the month three months before signup, so a newly connected account could contain an older receipt template on day one. Replacing an old parser with a parser for only the current design would silently lose valid purchases from that lookback window.
The same fixture strategy covered product pages. Tests loaded tests/Data/ProductPages/<fixture> when present, ran the
real store definition, and asserted exact product identity and price. The helper could fetch and save a page when a
fixture was missing or explicitly refreshed, but normal test runs were local and deterministic.
Fixtures proved support for captured formats, not the merchant's current layout. When a live failure or deliberate refresh revealed a new layout, we kept the old fixture, added the new sample, and changed the smallest parser rule that made both pass.
Receipt and PDF extraction
PDF inputs included clean embedded text, scrambled glyph order, image-only scans, malformed tables, and merchant-specific encoding problems. Tjommi retained three parsing approaches because an established merchant parser could remain more accurate than a newer general extractor.
PDF text extraction and landmark parsing
The following text came from an Elkjøp fixture PDF after Smalot\PdfParser called getText(). I have replaced
customer, employee, order, payment, date, SKU, and product-specific values, but kept the merchant's labels, separators,
spacing, currency format, and line order:
PhoneHouse Kløverhuset NO 947 054 600 MVA BANK: 6005 06 33198
Strandgaten 13-15 Foretaksregisteret TLF: 210 021 21
5013 Bergen kloverhuset.ph@elkjop.no
Kløverhuset
Unik ID: [UNIQUE ID]
Kopi
KONTANT [TERMINAL ID]
OLA NORDMANN ----------------------------------------
EKSEMPELGATEN 1 Ordrenr.: [ORDER ID] 03.04.20 (3) 1-12:34
0001 OSLO Selger..: [EMPLOYEE]
Telefon.: [PHONE]
E-post..: [EMAIL]
--------------------------------------------------------------------------------
Varekode Beskrivelse Ldato Pris Ant Beløp
--------------------------------------------------------------------------------
[SKU] EXAMPLE PRODUCT 1690,00 1 1.690,00'
EXAMPLE MANUFACTURER PART
----------
TOTAL (Mva inkl. med NOK 338,00) NOK 1.690,00
03/04/2020 12:34 | ATC: [ATC]
KJØP NOK 1690.00 | AID: [AID]
BankAxept | TVR: [TVR]
**** **** **** **00 00-0 | STAN: [STAN]
TERM: [TERM] | AUT KODE: [AUTH CODE]
KA1 _ BAX: [BAX] | POSREF: [POS REF]
Operatørnr: [EMPLOYEE] | 00 GODKJENT
REF: [REFERENCE] AUTORISERT
Grunnlag MVA
': 25% mva 1.352,00 338,00
Alt om 50 dager åpent kjøp på elkjop.no/kundefordeler.
Produktet må være i samme stand som da du kjøpte det, rengjort godt
og uten riper eller andre bruksmerker.
...
The text was regular enough to parse. The customer block preceded a long rule. Varekode, Beskrivelse, Pris, Ant,
and Beløp arrived in the same order. Product rows began with a SKU, wrapped description lines began with spaces, and
runs of spaces separated columns. TOTAL followed the product region, with the payment and VAT blocks after it.
The parser therefore did not need to understand the whole receipt. It needed to chop away everything outside a known window, recognize the grammar inside that window, and ignore the rest. The visual layout had collapsed, but the document generator's ordering had survived. That ordering was consistent across the captured receipt family.
The earliest scanners converted the PDF into text, found stable words, sliced the relevant region, and parsed it
locally. Komplett invoices used helpers such as splitBefore("Leveringsadresse"),
splitBefore("betalingsinformasjon."), and wordAfter("Ordrenummer"). Elkjøp's in-store receipt makes the technique
even more concrete because the source was a visually aligned table that the embedded-text parser flattened into lines.
Generated documents often preserve label order while logos, addresses, and fonts change. Labels such as Ordrenummer,
MVA, Netto, and Total therefore served as stable anchors.
In that input distribution, landmark parsing can be more robust than physical coordinates. This scanner first captured
everything between the Beløp heading and a dashed footer. It then treated runs of two or more spaces as column
boundaries:
// app/Modules/PdfScanner/Scanners/ElkjopInstorePdfScanner.php
class ElkjopInstorePdfScanner extends PdfScanner
{
public function scan()
{
$linesText = Regex::match("/Beløp\n-+\n(.+)----------/ms", $this->text)->group(1);
$items = collect(explode("\n", $linesText))
->filter(fn ($line) => trim($line) !== "")
// A wrapped product-name continuation is indented and has no SKU.
->reject(fn ($line) => Str::startsWith($line, " "))
->map(function ($line) {
$columns = Str::of($line)->split("/[ ]{2,}/")->toArray();
// Some receipts contain an extra LDato column.
if (count($columns) === 6) {
$columns = array_values(Arr::except($columns, 2));
}
if (count($columns) !== 5 || $columns[0] === "Varekode") {
return null;
}
return [
"sku" => $columns[0],
"name" => $columns[1],
"unit_price" => $this->parseNumber($columns[2]),
"quantity" => $this->parseNumber($columns[3]),
"total_price" => $this->parseNumber($columns[4]),
];
})
->filter()
->reject(fn ($item) => Str::contains($item["sku"], ["RETURNSTORE", "DELSBSSITE"]))
->reject(fn ($item) => $item["quantity"] <= 0)
->values();
$date = Regex::match("@(\\d\\d/\\d\\d/\\d\\d\\d\\d \\d\\d:\\d\\d)@", $this->text)
->groupOr(1, "") ?: Regex::match("@(\\d\\d\\.\\d\\d\\.\\d\\d)@", $this->text)->groupOr(1, "");
return [
"order_date" => $date ?: null,
"total_amount" => $items->sum("total_price"),
"items" => $items->toArray(),
];
}
// parseNumber() removes apostrophe and thousands separators,
// then changes the decimal comma to a decimal point.
}
The parser isolated the item region, reconstructed columns from spacing, normalized a known six-column variant into the five-column schema, ignored repeated headers and service rows, and rejected returns expressed as negative quantities. It accepted two historical date formats and calculated the receipt total from accepted product rows.
The tests preserve six in-store variants. They cover an AirPods receipt from 2019, an older megastore date format,
receipts with the optional column, decimal-comma prices, and different product-name lengths. One fixture is asserted as
SKU 26172, APPLE AIRPOD 2.0, quantity 1, unit price 1690, and total 1690; another proves that 299,25
survives the text conversion as 299.25. A separate web-receipt scanner and fixtures handle Elkjøp's other PDF family.
The factory chooses between them from the document contents, so “Elkjøp PDF” was not incorrectly treated as one format.
The split words, unexplained bytes, and merchant-specific replacements came directly from captured documents. They were executable knowledge about a specific document generator. Supporting a new high-volume format could be fast: collect a failed sample, identify stable landmarks, describe the local grammar between them, and add a fixture.
Textract blocks and merchant templates
AWS Textract gave us a different intermediate representation. For PDFs, the service asynchronously analyzed FORMS and
TABLES; Tjommi placed a temporary object in a same-region S3 bucket, polled the analysis job, parsed the returned
block graph, and deleted the temporary object. Images could use synchronous DetectDocumentText bytes directly.
TextractResponse normalized AWS blocks into three things our templates understood:
- linear text;
- form entries with key, value, and confidence;
- zero-indexed table arrays.
The merchant template was a collection of independent resolvers for canonical receipt fields:
// stores/powerno.php: condensed retailer integration definition
return [
// ... domain, region, product scraper, search adapter
"scanner" => Template::forReceipt(
lineItems: Template::tableToArray(
tableIndex: fn ($table) => in_array(
"Beskrivelse",
array_column($table, 1),
),
mapping: [
"sku" => 0,
"name" => 1,
"unitPrice" => 3,
"quantity" => 4,
"totalPrice" => 5,
],
postProcess: fn ($rows) => Template::parseLineItems($rows),
),
orderReference: Template::findByFormKey("Ordrenr."),
totalAmount: Template::fromTableCoordinate(
tableIndex: fn ($table) => collect(array_column($table, 1))
->contains(fn ($cell) => Str::startsWith($cell, "TOTAL (Mva")),
row: fn ($row) => Str::startsWith($row[1], "TOTAL (Mva"),
column: "last",
postProcess: fn ($value) => NumberParser::parse($value),
),
// ... purchase time and VAT resolvers
),
// ... commercial seed data
];
The template did not ask where a table sat on the page. It asked which reconstructed table looked like the product table, based on its contents. Address length, logo placement, and page margins could move without changing that answer.
Each resolver failed independently. The result kept successful fields plus a failure record for the ones that threw,
then a predicate decided whether the partial interpretation was believable enough to accept. variation() provided
ordered alternatives when the same merchant emitted multiple table shapes.
The helpers included narrow rules learned from the fixtures. One coordinate selector could return the highest number on
a row, the lowest, or the lowest excluding 25, since 25 was usually the VAT percentage beside the amount. This rule
applied to invoice layouts where the VAT percentage otherwise looked like a monetary value.
Normalized document text and schema extraction
By 2022 the document adapter could accept a StoredEmail, MailMessage, attachment, or receipt and produce text:
// app/Modules/OpenAI/Support/TextConverter.php
class TextConverter
{
// ... convert() also accepts StoredEmail, MailMessage, and Receipt
public static function attachment(
MailAttachment $attachment,
bool $ocrFallback = false,
): ?string {
if ($attachment->isImage()) {
return self::image($attachment->getContent());
}
if ($attachment->isPdf() || $attachment->isOctetStream()) {
return self::pdf($attachment->getContent(), $ocrFallback);
}
return null;
}
public static function pdf(string $bytes, bool $ocrFallback = false): ?string
{
try {
$text = (new Parser())->parseContent($bytes)->getText();
if (! $text) throw new Exception("No text found");
return preg_replace('/[[:cntrl:]]/', ' ', $text);
} catch (Throwable) {
// image() sends the bytes to Textract's text detector.
return $ocrFallback ? self::image($bytes) : null;
}
}
// ... HTML simplification and Textract-backed image()
}
The adapter used the embedded text layer when available and invoked OCR only when requested and needed. The next parser received normalized text in either case.
OpenAI receipt parsing
The first OpenAI receipt parser landed in June 2022. The wrapper posted directly to /v1/completions and defaulted to
text-davinci-002. In December it moved to text-davinci-003. The later code added a chat-completions path using
gpt-3.5-turbo, while retaining the completion-era parser as a legacy method.
Davinci received text rather than receipt images. OCR, HTML simplification, or PDF extraction first converted each document into text; the completion model then reconstructed the receipt fields.
The first prompt was short and lived in app/Modules/OpenAI/Prompts/receipt_parser_v1.txt. Its original wording is
preserved here:
// app/Modules/OpenAI/Prompts/receipt_parser_v1.txt
extract the order number, the date, the shipping amount, the tax amount,
the total amount and the line items including product name, quantity price,
sku from the receipt in JSON, format the prices using period as decimal separator of they that is not already used
and remove the currency, use the keys "name", "qty", "price" and "sku" for the products and put it under a key called "lineItems",
use "orderRef" for order number, "date" for the date, "shippingAmount" for shipping amount, "taxAmount" for tax amount and "totalAmount" for the total amount
if you dont find any suitable values use null
the date should be formatted using the following template: YEAR-MONTH-DAY
the order number is a sequence of numbers, if there are letters inside it use null instead
RECEIPT
[REPLACE_TEXT]
THE OUTPUT IN JSON
The original wrapper made all the early-completions knobs explicit:
// app/Modules/OpenAI/OpenAI.php
class OpenAI
{
public function complete(
string $prompt,
string $engine = "text-davinci-003",
int $maxTokens = 500,
float $temperature = 0.0,
float $topP = 1.0,
int $n = 1,
string $user = "Tjommi",
// ... stop, penalty, best-of, and logging arguments
): Completion {
$response = Http::timeout(config("tjommi.default_openai_timeout"))
->withHeaders(["OpenAI-Organization" => $this->organizationId])
->withToken($this->apiKey)
->post("https://api.openai.com/v1/completions", [
"model" => $engine,
"prompt" => $prompt,
"max_tokens" => $maxTokens,
"temperature" => $temperature,
"top_p" => $topP,
"n" => $n,
"user" => $user,
// ... remaining completion parameters
]);
// ... convert 429s to a retryable failure and write usage metrics
return Completion::fromResponse(response: $response, prompt: $prompt);
}
}
The model output was normalized into a typed receipt and nested line-item DTO:
// app/Modules/OpenAI/Data/ReceiptV1.php
class ReceiptV1 extends DataTransferObject
{
public ?Carbon $date = null;
public ?string $orderRef = null;
public ?float $taxAmount = null;
public ?float $totalAmount = null;
public ?float $shippingAmount = null;
public ?string $currency = null;
/** @var Collection<LineItemV1> */
public $lineItems = [];
public static function parse($data): ?self
{
if (! $data) return null;
return new self([
"date" => rescue(fn () => Carbon::parse($data["date"]), null, false),
"orderRef" => trim($data["orderRef"] ?? "") ?: null,
"totalAmount" => (float) NumberParser::parse($data["totalAmount"]),
"lineItems" => collect($data["lineItems"] ?? [])
->map(fn ($item) => new LineItemV1([
"name" => trim($item["name"] ?? "") ?: null,
"sku" => trim($item["sku"] ?? "") ?: null,
"quantity" => ($item && $item["quantity"])
? NumberParser::parseInt($item["quantity"])
: null,
"price" => (float) NumberParser::parse($item["price"] ?? null),
])),
// ... taxAmount, shippingAmount, and currency
]);
}
}
// app/Modules/OpenAI/Data/LineItemV1.php
class LineItemV1 extends DataTransferObject
{
public ?string $name = null;
public ?string $sku = null;
public ?int $quantity = null;
public ?float $price = null;
}
ReceiptV1::parse() used Carbon for the date, trimmed blank identifiers to null, passed monetary fields through
NumberParser, and built the nested line items. The first prompt above used the key qty; its original
ParseReceiptWithAI job handled that key directly. The later ReceiptV1 path accompanied prompt version 4 and expected
quantity, which shows the interface change between the two parser generations.
The queued receipt-creation job applied another acceptance gate before persistence: both total and order reference had to be present.
// app/Modules/EmailScanner/Jobs/CreateReceiptFromStoredEmailUsingOpenAI.php
class CreateReceiptFromStoredEmailUsingOpenAI implements ShouldQueue
{
protected function receiptDataIsValid(OpenAIEmailScanner $scanner): bool
{
return ! empty($scanner->totalAmount())
&& ! empty($scanner->orderReference());
}
protected function performScanning(): void
{
$message = MailMessage::fromStoredEmail($this->storedEmail);
// ... invoke OpenAIEmailScanner, validate fields, and resolve/create Domain
$state = $domain->approved
? Receipt::COMPLETED
: Receipt::UNVERIFIED;
$receipt = Receipt::query()->updateOrCreate(
[
"user_id" => $this->storedEmail->user_id,
"source" => Receipt::SOURCE_EMAIL_AI,
"source_id" => $message->getMessageId(),
],
[
"domain_id" => $domain->id,
"state" => $state,
"order_reference" => $scanner->orderReference(),
"total_amount" => $scanner->totalAmount(),
"shipping_amount" => $scanner->shippingAmount(),
"discount_amount" => $scanner->discountAmount(),
"scanner" => $scanner::class,
// ... purchase time, currency, payment method, and asset path
],
);
// ... persist line items, resolve products where possible, and log outcome
}
}
An unknown sender host could create a hidden, autogenerated, unapproved Domain. That forced the receipt into
UNVERIFIED. An already known hand-scanned domain was rejected from this path to avoid treating refunds or other
merchant email as a second receipt. Unresolved line items were still retained with their text and textual SKU so the
extraction result was inspectable even when product matching failed.
Every terminal result wrote an AI scan log containing the prompt version, error code, attempt metadata, extracted data,
and source message context. A 429 reset the pending row's assigned account to zero so it could be scheduled again;
success or a non-rate-limit terminal failure removed the pending entry. The surrounding jobs provided a backlog,
validation boundary, audit record, and requeue policy around the model request.
OCR preprocessing and semantic extraction
OCR answered "which characters are probably on this page?" The language model answered questions we had previously encoded as programs:
- Which detached number is the total rather than tax or shipping?
- Does this mangled line describe a product, a discount, or a payment method?
- Which identifier is the order number?
- Which price belongs to which line when whitespace and columns are gone?
- Which missing fields should be
nullrather than guessed?
We could feed it text whose table structure had been destroyed and often receive coherent JSON. This covered ambiguity that previously required explicit code for encoding damage, Nordic and American number formats, malformed HTML, PDF landmarks, and phantom OCR columns.
An experimental fine-tuning path used a Curie model and generated receipt examples. The repository contains the training-data preparation and a test against that model, while the production wrapper continued to default to the general Davinci completion model.
Model output validation and receipt creation
CreateReceiptFromStoredEmailUsingOpenAI existed beside CreateReceiptFromStoredEmail. Both jobs reconstructed a
MailMessage, selected an extractor, and wrote the same receipt fields.
The AI path still had to:
- convert the email or attachment into text;
- parse the model output into the canonical DTO;
- infer a provisional merchant and region;
- create an unverified receipt;
- resolve every line item through the existing product machinery;
- record prompt version, result, errors, and usage;
- either complete the receipt or leave evidence for review.
If the sender belonged to a known, non-autogenerated merchant, the long-tail path stopped because a proper scanner already existed. If the model found a receipt from an unknown sender, Tjommi could create a hidden, unapproved merchant record and keep the result unverified. That made the feature a merchant-discovery mechanism as much as a parser.
The basic acceptance gate was intentionally modest: a total and order reference had to exist. Missing or suspicious data still flowed through downstream verification rather than becoming financial truth because a model returned JSON.
The model and API could change without altering the following data boundary:
raw evidence
→ deterministic text/OCR adapter
→ semantic extraction
→ typed canonical DTO
→ validation and identity resolution
→ unverified durable record
→ business workflow
Manual receipt repair
Not every receipt could be completed without review. The backoffice included a custom Livewire receipt annotator for checking the source file, seeing what each extractor found, correcting fields, and sending the record back through the same downstream flow.
The list view filtered receipts by region, merchant, source, file type, state, dates, mailbox provider, deleted-account status, and payment-method status. Operators could select a batch and queue one of three actions: rescan, guess the merchant, or parse with OpenAI. Image and PDF rescans were staggered by ten seconds, while HTML receipts could be requeued immediately.
The individual annotator kept the source asset beside editable receipt fields and line items. It could show the normalized plaintext input, rotate an image, download the source, run a normal rescan, or force a synchronous rescan of the originating email. Product suggestions used the same lookup path as automatic extraction: merchant SKU first, then stored products, then the merchant's search adapter.
The OpenAI tab was deliberately explicit. Its button was labelled Magic AI Extraction. It converted the asset to text
if needed, ran the legacy parser, cached the response for an hour using md5($receipt->asset), and displayed the raw
JSON. Fill receipt with data was a separate operation. An operator could inspect and reject an implausible result
before it changed the receipt.
// app/Http/Livewire/ReceiptAnnotator.php
class ReceiptAnnotator extends Component
{
public $openAIData = [];
public $openAICacheKey = null;
public $receiptAsText = null;
public function parseWithAI(OpenAI $openAI)
{
if (! $this->receiptAsText) {
$this->generateTextFromAsset();
}
$this->openAIData = Cache::remember(
$this->openAICacheKey,
now()->addHour(),
fn () => $openAI->parseReceiptLegacy($this->receiptAsText),
);
}
public function updateReceiptWithAI()
{
// ... map order reference, totals, shipping, tax, and date
// ... default currency from $this->receipt->domain->region
$this->receipt->save();
collect($this->openAIData["lineItems"] ?? [])
// ... normalize quantity and price into LineItem objects
->each(function (LineItem $lineItem) {
dispatch(function () use ($lineItem) {
// ... ProductFinder resolves the product and writes ReceiptItem
})->onQueue(Queues::default);
});
}
// ... manual field editing, rescans, Textract mapping, and completion
}
The Textract tab exposed a different kind of repair. It displayed extracted key-value forms with confidence scores and each detected table. An operator selected a table, labelled each column as SKU, name, quantity, unit price, total price, or ignore, excluded non-product rows, and queued the selected table for product matching. For HTML email receipts, a separate tab displayed the deterministic scanner's raw output in the same interface.
Saving the editor validated the fields, updated or created ReceiptItem rows, recomputed existing price-event
differences, and wrote the edited data to ActivityLog::RECEIPT_ANNOTATOR. Marking the repaired receipt complete
emitted ReceiptProcessed, putting it back into the ordinary downstream pipeline instead of creating a separate manual
path.
The scanner-health view closed the loop at the merchant level. It grouped receipts by merchant and scanner, then showed completion rate, pending count, last activity, and later ticket outcomes. A weakening parser could therefore be found from aggregate behavior instead of waiting for enough individual support cases to reveal the pattern.
Retailer configuration registry
Receipt extraction only gave us what the customer paid. Tjommi also needed to find the same product on the retailer's site and keep observing its price.
Tjommi stored retailer integrations as executable PHP definitions in stores/. The final repository contains 2,379
definitions. EcommerceStoreServiceProvider bound the registry; when resolved, EcommerceStores loaded every file and
turned each returned array into an object.
Each definition could describe:
- domain, region, and base URL;
- product-field selectors or a complete scraper callback;
- product discovery through sitemap, categories, crawl, feed, or Shopify;
- a native search implementation;
- headers, cookies, proxy tier, country, and wait time;
- Textract/PDF receipt templates, separate from the HTML email-scanner registry;
- ticket-automation steps;
- commercial rules such as price-match availability and duration.
Each retailer's settings lived together instead of in conditionals spread across the application.
The loader was small enough to understand in one screen. It found PHP files, executed them, converted each returned
array through EcommerceStore::fromConfig(), then bound the resulting collection into Laravel's container:
// app/Modules/EcommerceStore/EcommerceStores.php
class EcommerceStores
{
public static function loadFromFolder(?string $folder = null): self
{
$stores = collect(self::loadConfigFromFolder($folder))
->map(fn ($config) => EcommerceStore::fromConfig($config))
->all();
return new self($stores);
}
public static function loadConfigFromFolder(?string $folder = null): array
{
$files = Finder::create()
->files()
->name("*.php")
->in(realpath($folder ?? base_path("stores")));
foreach ($files as $file) {
$stores[] = require $file->getRealPath();
}
return $stores ?? [];
}
// ... lookup by URL, domain, code, proxy policy, or automation support
}
Because these were executable files rather than serialized configuration, a definition could import reusable objects, closures, enums, and arbitrary PHP. The validator explicitly required domain, region, and an extraction implementation; construction also expected a base URL. The remaining capabilities were optional composition.
Store definitions for scanning and scraping
The definitions did not replace the relational data model. They were the static retailer capability manifest from which the application built two projections.
At runtime, EcommerceStore::fromConfig() created an adapter with fetching, extraction, search, discovery, proxy, PDF,
and automation behavior. During seeding, DomainSeeder loaded the same raw arrays, resolved the configured region, and
updateOrCreated the merchant's Domain record from the seed block plus normalized fields such as base URL, proxy
country, and whether a search engine existed. Support email addresses and support domains became their own relations.
Region represented a market rather than a geographic decoration. It carried currency, locale, and market code. The
same retailer brand could therefore have separate Danish, Norwegian, and Swedish definitions, product namespaces,
commercial terms, and regional proxy exits.
Domain was the durable hub. Products, receipts, tickets, price events, support addresses, and the region all related
to it. The executable definition answered “how do we integrate with this retailer?” The database answered “what has
happened for this retailer?” A definition could change without rewriting historical receipts, scrapes, or tickets.
The boundary is visible in the interpreter:
// app/Modules/EcommerceStore/EcommerceStore.php
class EcommerceStore
{
public static function fromConfig(array $store): self
{
self::throwIfInvalidStoreDefinition($store);
return (new self())
->setDomain($store["domain"])
->setRegion($store["region"])
->setBaseUrl($store["base_url"])
->setProductFields($store["product_fields"] ?? null)
->setProductDiscovery($store["product_discovery"] ?? null)
->setProxyRequirement($store["proxy"] ?? ProxyRequirement::none)
->setSearchEngine($store["search"] ?? null)
->setPdfScannerTemplates($store["scanner"] ?? null)
// ... product-page filter, proxy country/wait, ticket automation,
// scrape override, callbacks, and custom headers
->setScrapeCookies($store["scrape_cookies"] ?? null);
}
public function scrapePage(string $html, string $url, $actualUrl = null)
{
// ... reject pages through an optional product-page filter
$data = is_array($this->productFields)
? Fields::make($this->productFields)->extract($html, $url)
: call_user_func($this->productFields, $this, $html, $url, $actualUrl);
// ... run an on-scraped callback and handle multi-product output
$data["url"] = $url;
return new StoreScrapeData($data);
}
// ... scrapeUrl() is a stronger escape hatch that can replace fetching too
}
The normalized result was deliberately small: name, URL, price, EAN, and merchant SKU. Product persistence used
(domain_id, sku) as identity while allowing name, EAN, and URL to be refreshed. This meant wildly different retailer
integrations converged before they entered price history and receipt matching.
Kremmerhuset: declarative store definition
At the simple end, Kremmerhuset needed no callback at all. Three fields each specify a selector, a value source, and, only for price, a normalizer. Search is another configured adapter:
// stores/kremmerhusetno.php
// ... imports
return [
"domain" => Domain::KREMMERHUSET_NO,
"region" => Region::REGION_NORWAY,
"base_url" => "https://kremmerhuset.no/",
"product_fields" => [
Field::make("name")->selector("span[itemprop='name']")->text(),
Field::make("sku")->selector("div.field--name-sku")->text(),
Field::make("price")->selector("div[itemprop='offers']")->text()->asNumber(),
],
"search" => new SiteSearch("/search", "h4.product-title a", "query"),
"seed" => [
"name" => "Kremmerhuset",
"host" => "kremmerhuset.no",
"open_purchase_period" => 30,
"has_price_match" => false,
"price_events_enabled" => true,
// ... display and support metadata
],
];
The definition states each scraper operation directly: select the product name, SKU, and offer; read their text; parse
the offer as a number. The seed block also records the commercial policy. Tjommi could monitor Kremmerhuset even
though the store did not offer price matching.
Komplett: selective custom parsing
Komplett still used the declarative field pipeline, but its SKU appeared inside presentation text and needed one small resolver. The definition also declared that both pages and search required a premium Norwegian exit:
// stores/komplettno.php
// ... imports
return [
"domain" => Domain::KOMPLETT,
"region" => Region::REGION_NORWAY,
"base_url" => "https://www.komplett.no/",
"proxy" => ProxyRequirement::premium,
"proxy_country" => ProxyCountry::norway,
"product_fields" => [
Field::make("name")->selector(".product-main-info-webtext1 span")->text(),
Field::make("price")->selector("span.product-price-now")->text()->asNumber(),
Field::make("sku")
->selector(".product-main-info-partnumber-store")
->text()
->value(function ($value) {
return trim(explode(" ", explode("/", $value)[0])[1]);
}),
Field::make("ean")->selector("span[itemprop='gtin']")->text(),
],
"search" => new SiteSearch(
path: "/search",
selector: "a.product-link",
proxyRequirement: ProxyRequirement::premium,
proxyCountry: ProxyCountry::norway,
followProductPageRedirect: true,
timeout: 20,
),
"seed" => [
"name" => "Komplett",
"promise_duration" => 14,
"open_purchase_period" => 14,
"has_price_match" => true,
"price_comparison_enabled" => true,
// ... host, support addresses, logo, and score fields
],
];
The SKU callback stayed local to Komplett. Adding a shared split-and-index operation for one merchant would have made the DSL harder to understand. The remaining fields stayed declarative.
Elgiganten: first-party APIs and custom parsing
Elgiganten Denmark sits at the other end. By this point the retail HTML was not the best source of truth. Its definition used two persisted GraphQL operations: one for product identity and one for dynamic pricing. The SKU came from the final URL segment, and browser-shaped headers made the requests look like the retailer's own frontend:
// stores/elgigantendk.php
// ... imports
return [
"domain" => Domain::ELGIGANTEN_DK,
"region" => Region::REGION_DENMARK,
"base_url" => "https://www.elgiganten.dk/",
"product_fields" => function (EcommerceStore $store, $html, $url) {
$sku = Str::of($url)->afterLast("/")->trim();
$headers = [
"referer" => "https://elgiganten.dk/",
"user-agent" => UserAgent::random([
"os_type" => "Windows",
"device_type" => "Mobile",
]),
"x-app-mode" => "b2c",
// ... the headers used by Elgiganten's storefront
];
$product = Http::timeout(5)->withHeaders($headers)
->get("https://www.elgiganten.dk/cxorchestrator/dk/api", [
"operationName" => "getProductWithDetails",
"variables" => json_encode(["articleNumber" => $sku, "isB2B" => false]),
// ... appMode, user, and the full persisted-query hash
"extensions" => '{"persistedQuery":{"sha256Hash":"..."}}',
])->json("data.product");
$pricing = Http::timeout(5)->withHeaders($headers)
->get("https://www.elgiganten.dk/cxorchestrator/dk/api", [
"operationName" => "getProductWithDynamicDetails",
"variables" => json_encode([
"articleNumber" => $sku,
"withCustomerSpecificPrices" => false,
]),
// ... appMode, user, and the full persisted-query hash
"extensions" => '{"persistedQuery":{"sha256Hash":"..."}}',
])->json("data.product.activePricing");
return [
"name" => $product["title"],
"sku" => $product["articleNumber"],
"ean" => $product["gtin"],
"price" => $pricing["value"] + 0,
];
},
"search" => new class implements SearchEngine {
public function search(EcommerceStore $store, $searchTerm): ?string
{
// Call Elgiganten's getSuggestionResults operation, keep productName
// suggestions, and return the first SimpleUrlB2C product URL.
// ...
}
},
"scanner" => Template::forReceipt(
lineItems: Template::tableToArray(
tableIndex: fn ($table) => in_array("Beskrivelse", array_column($table, 1))
|| in_array("Beskrivelser", array_column($table, 1)),
startRowIndex: 1,
mapping: [
"sku" => 0,
"name" => ["index" => 1, "value" => fn ($text) => trim(Str::before($text, "S/N"))],
"unitPrice" => 2,
"quantity" => 4,
"totalPrice" => 5,
],
postProcess: fn ($items) => Template::parseLineItems($items),
),
orderReference: Template::findByFormKey(["Ordrenummer", "Fakturanummer"]),
// ... purchase time, VAT, and total resolvers
),
// ... seed policy and support metadata
];
Callables covered stores whose own API was a better data source than the DOM. The callable still returned
StoreScrapeData, and the native search adapter still returned a URL. The Textract receipt template remained in the
same store definition.
The scrape_override hook could replace the fetch itself. Elkjøp used it to normalize several URL shapes and Queue-it
redirects before requesting product information by article number. Field arrays covered ordinary HTML, callables read
local APIs or embedded state, and a full override handled custom transport.
Product-page field extraction DSL
Field encoded the common selector pipeline:
// app/Modules/Scraping/Fields/*: evaluation sequence
CSS selector
→ text | HTML | attribute | raw callback
→ optional transform
→ trim | numeric parsing | EAN validation | JSON path
FieldCollection projected repeated product cards. FieldCombination joined values from multiple fragments.
FieldVariation tried alternate selectors for stores whose templates differed across categories or deployments.
Fields evaluated the collection and returned one stable associative shape.
The fluent methods were deliberately small:
// app/Modules/Scraping/Fields/Fields.php: representative configuration
$fields = Fields::make([
Field::make("name")
->selector("h1.product-title")
->text()
->trim(),
Field::make("price")
->selector('[data-price]')
->attribute("data-price")
->asNumber(),
FieldVariation::make([
Field::make("ean")->selector('[itemprop="gtin13"]')->text()->asEan(),
Field::make("ean")->raw(fn (Crawler $node) => extractFromScript($node))->asEan(),
]),
FieldCombination::make("sku", [
Field::make("brand")->selector(".brand")->text(),
Field::make("part")->selector(".part-number")->text(),
])->join("-"),
FieldCollection::make("variants")
->selector(".variant-card")
->fields([
Field::make("name")->selector(".name")->text(),
Field::make("sku")->selector("[data-sku]")->attribute("data-sku"),
]),
]);
$product = $fields->extract($html, $url);
The arbitrary callback was essential. A DSL that handled common shops but could not represent the remaining cases would have pushed complexity outside the abstraction. Here the common cases remained declarative, while a merchant could still run ordinary PHP when its SKU lived inside an inline script, tracking URL, GraphQL response, or another unusual location.
Extraction failures generally returned null, allowing variations and higher-level strategies to continue. This also
made a missing selector indistinguishable from an exception inside a transform.
Numeric and currency normalization
Price strings were their own adversarial input:
// app/Support/NumberParser.php: representative inputs
1 299,-
1.299,-
1.299,00
1,299.00
€ 1.299,95
1299 kr
NumberParser stripped currency, normalized whitespace, handled the Nordic ,- convention, and inferred the roles of
dots and commas from their positions. Centralizing these ecommerce-specific rules kept them out of individual store
definitions.
Product extraction fallback chain
Writing selectors for every store was expensive, so ProductExtractor tried reusable strategies in a fixed order and
accepted the first result:
// app/Modules/Scraping/ProductExtractor.php
class ProductExtractor
{
/** @return Data\Product[]|null */
public function extract(string $html, string $url): ?array
{
if (trim($html) === "") return null;
foreach ($this->strategies as $strategy) {
try {
if ($products = $strategy->extract($html, $url)) {
return $products;
}
} catch (Throwable) {
// ... try the next interpretation
}
}
return null;
}
// ... extractWithStrategy() supports targeted debugging and tests
}
The service provider registered:
// app/Modules/Scraping/ScrapingServiceProvider.php: registration order
Shopify
→ JSON-LD
→ structured schema parser
→ hand-rolled schema.org microdata
→ Google AdWords ecommerce variables
→ Google Analytics enhanced-ecommerce dataLayer
The order reflected specificity and usefulness. Shopify product pages often exposed a .json representation, including
variants, SKU, barcode, and price. JSON-LD and schema.org existed to tell search engines which thing on the page was the
product. Advertising and analytics scripts existed to tell Google the product name, identifier, and value.
The visible DOM could be a JavaScript application with generated class names and no stable semantics, but the marketing stack still needed accurate machine-readable commerce data. We used that data as an accidental product API.
Store definitions could combine the generic extractor with bespoke fields. That kept the wide, standards-based coverage while letting one unstable or absent property be patched locally.
Strategies returned an array because one page could describe multiple variants. Shopify's product JSON emitted one DTO
per variant, including its own SKU, barcode, and price. The JSON-LD strategy handled a single offer or an offer list and
normalized common sku, gtin, price, and URL locations. The ordinary scrape path normally selected the first
candidate, so returning multiple variants was more expressive than the final persistence behavior could fully exploit.
The chain returned the first usable result. It did not score or compare competing results, and strategy exceptions were often swallowed so the next extractor could run.
Normalized product persistence
The configured scraper used a narrow DTO, while the generic fallback extractors could retain more page metadata:
// app/Modules/EcommerceStore/Data/StoreScrapeData.php
class StoreScrapeData extends DataTransferObject
{
public $name;
public $url;
/** @var int|float|null */ public $price;
/** @var int|string|null */ public $ean;
/** @var int|string|null */ public $sku;
}
// app/Modules/Scraping/Data/Product.php
class Product extends FlexibleDataTransferObject
{
public ?string $name;
public ?string $brand;
public ?string $title;
public $sku;
public ?string $mpn;
public ?string $gtin;
public $price;
public ?string $image;
public ?string $url;
// ... convert(StoreScrapeData) maps ean to gtin and copies
// name into both name and title
}
StoreScrapeData contained the five values required by a retailer definition: name, URL, price, EAN, and SKU. Generic
extractors could also retain brand, MPN, title, and image. The store-specific result was converted into Data\Product
before persistence, so both extraction paths used the same database write.
A missing SKU rejected the result. Otherwise the scraper upserted a Product by (domain_id, sku), refreshed its name,
EAN, and URL, and appended a separate Scrape observation when a price was present:
// app/Modules/Scraping/Scraper.php
class Scraper
{
// ... fetch and extract through a store definition or generic strategy
protected function storeProduct(Data\Product $data, Domain $domain): ?Product
{
if (! $data->sku) {
throw new Exception("Sku cannot be null");
}
$product = Product::updateOrCreate(
["domain_id" => $domain->id, "sku" => $data->sku],
[
"name" => $data->name,
"ean" => $data->gtin,
"url" => urldecode($data->url),
],
);
if ($data->price) {
$product->addScrape($data->price);
}
// ... clear the product's failed-scrape counter
return $product->fresh();
}
}
Discovery and search produced URLs, the configured extractors produced StoreScrapeData, and one persistence action
maintained product identity and append-only price history.
Product discovery, scheduling, proxies, and failed scrapes
URL discovery
A product URL could arrive from a receipt, but Tjommi also discovered catalogs through XML sitemaps, sitemap indexes, text maps, paginated category pages, recursive crawls, Shopify products, and merchant feeds.
Discovery and extraction were separated. A discovery adapter only had to emit candidate URLs. The ordinary scraping path still fetched, extracted, normalized, and persisted them.
SitemapDiscovery removed duplicates and the site root, applied the retailer's URL predicate, chunked candidates in
groups of ten, and dispatched ordinary scrape batches. Sitemap-index discovery added a second traversal level and gzip
handling. Category discovery extracted product links from one page, queued them, followed the configured next-page
selector, and rate-limited work per base URL in Redis. None of these classes understood product prices; their output was
only a stream of URLs for the shared scraper.
Batch transport and scheduled rechecks
For direct requests, ScrapeUrlBatch deduplicated URLs, issued pooled requests, then handed each response into the same
scrapeHtml() function used by a single job. Batching changed the transport economics without forking the parser or
persistence logic.
Scheduled rechecks were value-aware. The command selected linked receipt items whose purchase price was at least 150, whose guarantee window plus slack was still live, and whose product URL remained usable. Direct stores were batched; proxy-required stores were queued individually. The scheduler ran those groups at different times.
Proxy and retry policy
Anti-bot policy became a first-class enum:
// app/Modules/Scraping/Enums/ProxyRequirement.php
enum ProxyRequirement
{
case none;
case standard;
case standardWithJs;
case premium;
case premiumWithJs;
case stealthWithJs;
}
The HTTP macro translated that policy into ScrapingBee controls for JavaScript rendering, premium or stealth IPs, country, timeout, and wait. Proxy concurrency was guarded with a Redis funnel so an external provider limit could not turn a scheduled rescrape into a thundering herd.
Store definitions declared the proxy capability they required, while one transport adapter translated that capability into ScrapingBee options. A difficult definition could say:
// stores/rezetstoredk.php
// ... imports
return [
"domain" => Domain::REZETSTORE_DK,
"region" => Region::REGION_DENMARK,
"base_url" => "https://rezetstore.com/",
"proxy" => ProxyRequirement::stealthWithJs,
"proxy_country" => ProxyCountry::denmark,
"product_fields" => EcommerceStore::useGenericExtractor(),
"search" => new SiteSearch(
path: "/da/varer",
selector: ".ProductTeaser a",
query: "text",
proxyRequirement: ProxyRequirement::stealthWithJs,
proxyCountry: ProxyCountry::denmark,
timeout: 30,
),
// ... seed policy
];
That distinction controlled cost. Standard, JavaScript-rendered, premium, and stealth requests did not consume the
provider in the same way. Some stores needed a proxy only for search; some needed JavaScript only on one endpoint; some
needed the exit IP to match a specific market. ProxyCountry::auto mapped the configured Tjommi region to an exit,
while randomScandinavia distributed traffic when exact geography did not matter.
The provider mapping lived once:
// app/Modules/Scraping/ScrapingServiceProvider.php
class ScrapingServiceProvider extends ServiceProvider
{
public function register()
{
$withProxy = function (
ProxyRequirement $requirement = ProxyRequirement::standard,
int $timeoutInSeconds = 10,
ProxyCountry $country = ProxyCountry::randomScandinavia,
?int $wait = null,
): PendingRequest {
return $this->timeout($timeoutInSeconds)->withOptions([
"proxy" => sprintf(
"%s:%s@proxy.scrapingbee.com:8886",
config("services.scrapingbee.api_key"),
http_build_query(array_filter([
"render_js" => match ($requirement) {
ProxyRequirement::stealthWithJs,
ProxyRequirement::standardWithJs,
ProxyRequirement::premiumWithJs => true,
default => false,
},
"premium_proxy" => match ($requirement) {
ProxyRequirement::premium,
ProxyRequirement::premiumWithJs => true,
ProxyRequirement::stealthWithJs => null,
default => false,
},
"stealth_proxy" => $requirement === ProxyRequirement::stealthWithJs ?: null,
"country_code" => $requirement === ProxyRequirement::stealthWithJs
? null : $country->value,
"timeout" => $timeoutInSeconds * 1000,
"wait" => $wait,
], fn ($value) => ! is_null($value))),
),
"verify" => false,
]);
};
Factory::macro("withProxy", $withProxy);
PendingRequest::macro("withProxy", $withProxy);
// ... bindings
}
}
The production code also randomized randomScandinavia; the excerpt keeps the provider mapping readable. The store
scraper and proxy-aware search adapters called the same macro instead of each learning provider credentials and query
flags. An older ScrapeStack adapter still existed and was marked for replacement, so the snapshot contains some
historical overlap rather than one perfectly completed migration.
Direct requests defaulted to a 10-second timeout; proxied requests used 60 seconds. The proxy job could choose a country from the retailer region or randomize among supported Scandinavian exits. Provider concurrency defaulted to ten jobs, and the Redis funnel converted excess work into delayed retries instead of letting a scheduled run overload the account.
Retry behavior encoded real operational distinctions:
- a
404was product evidence, not a reason to keep buying proxy requests; - repeated not-found results incremented a counter and eventually marked the product discontinued;
- a proxy
401could mean exhausted provider credit, so retrying directly was useful; - a
403could trigger a stronger proxy tier and longer timeout; - request exceptions, parser exceptions, and merchant-specific no-shop cases were handled differently.
The retry callback made the escalation explicit:
// app/Modules/EcommerceStore/EcommerceStore.php
class EcommerceStore
{
public function scrapeUrl(string $url): ?ScrapeResult
{
$timeout = $this->proxyRequirement === ProxyRequirement::none
? config("tjommi.default_scraping_timeout", 10)
: 60;
$country = $this->proxyCountry === ProxyCountry::auto
? ProxyCountry::forRegion($this->getRegion())
: $this->proxyCountry;
$response = Http::timeout($timeout)
->when(
$this->proxyRequirement !== ProxyRequirement::none,
fn (PendingRequest $request) => $request->withProxy(
$this->proxyRequirement, $timeout, $country, $this->proxyWait,
),
)
->retry(2, 500, function ($exception, PendingRequest $request) use ($country) {
if ($exception->response->status() === 401) {
// Provider credits exhausted: make the retry direct.
$request->withOptions(["proxy" => null, "verify" => true]);
return true;
}
if ($exception->response->status() === 403) {
// The origin blocked us: escalate this retry.
$request->withProxy(ProxyRequirement::premium, 30, $country);
return true;
}
return $exception->response->status() !== 404;
})
->get($url);
// ... turn the response into ScrapeResult
}
}
Retries depended on the response. A 401 indicated a provider-account problem, 403 indicated origin blocking, and
404 usually indicated product removal. Each status followed a different path.
The scheduled path also separated direct and paid capacity. Direct products were refreshed twice daily in small HTTP batches. Proxy products were scheduled separately and dispatched one URL per job. Only that scheduled proxy job entered a global Redis funnel:
// app/Modules/Scraping/Jobs/ScrapeUrlWithProxy.php
class ScrapeUrlWithProxy implements ShouldQueue
{
public function handle(Scraper $scraper, EcommerceStores $stores): void
{
$store = $stores->findByUrl($this->url);
$concurrency = config("services.scrapingbee.concurrency", 10);
Redis::funnel("SCRAPINGBEE_CONCURRENCY")
->limit($concurrency)
->then(
fn () => $scraper->scrapeWithEcommerceStore($this->url, $store),
fn () => $this->release($concurrency),
);
}
// ... queue and retry configuration
}
That precision matters: the funnel protected the scheduled proxy-refresh path, not every proxied HTTP request in the
repository. Ordinary ScrapeUrl work called the store directly and had no explicit Redis gate in this snapshot. The
funnel separated queue-worker capacity from paid-provider capacity; excess jobs returned to the queue instead of
starting requests the account could not serve.
Failed-scrape records and fixture tests
Failures became durable FailedScrape records containing the URL, reason, attempted fields, raw HTML, headers, status
code and, on the generic path, the extractor label. Store-defined and generic extraction produced those records through
slightly different call paths, but both preserved the response required to repair a parser after the live page had
changed again. Retrying a record requeued the URL and removed that failure row so the next failure represented a fresh
attempt.
The test suite followed the same principle. It contained 2,102 fixture-backed store-extractor test classes. They loaded one of 4,936 captured product pages, resolved the actual registry definition, parsed the fixture, and asserted exact name, SKU, EAN, and numeric price. The base test helper touched the live network only when a fixture was missing. Repairing a scraper was therefore a local change against the page that broke it, not a flaky live-network test.
Product identity resolution through retailer search
Finding a lower price was useless if we could not prove that the purchased line and scraped page described the same thing. Product names were often abbreviated on receipts, variants mattered, and URLs changed.
ProductFinder tried an ordered set of increasingly expensive checks: merchant SKU, supplied URL, database name,
merchant search by name, and merchant search by SKU. It stopped at the first usable product:
// app/Modules/Scraping/Actions/ProductFinder.php
class ProductFinder
{
public function findByLineItem(LineItem $lineItem, Domain $domain): ?Product
{
$checks = [
fn () => $lineItem->sku
? Product::findBySkuAndDomain($lineItem->sku, $domain)
: null,
fn () => $lineItem->url
? $this->findByUrl($lineItem->url)
: null,
fn () => $lineItem->name
? $this->findByDatabaseSearch($lineItem->name, $domain)
: null,
fn () => $lineItem->name
? $this->findBySearchEngine($lineItem->name, $domain)
: null,
fn () => $lineItem->sku
? $this->findBySearchEngine($lineItem->sku, $domain)
: null,
];
foreach ($checks as $check) {
if ($product = rescue($check, false, false)) {
return $product;
}
}
return null;
}
// ... findByLineItemOptional() exposes the same checks as switches
}
Every rung ran inside Laravel's rescue. A dead search endpoint, malformed response, or failed product scrape rejected
that hypothesis without losing the entire inbox job. findByLineItemOptional() let a caller choose a narrower policy. A
scanner with a trustworthy merchant SKU could enable only SKU search; the OpenAI parsing path could try cached SKU and
name plus both search queries because its structured text began with less certainty.
Verifying retailer search results
The narrow SearchEngine contract returned one plausible URL:
// app/Modules/SearchEngines/SearchEngine.php
interface SearchEngine
{
public function search(EcommerceStore $store, $searchTerm): ?string;
}
That URL had to pass through the ordinary scraper. findByUrl() first checked the cache, synchronously dispatched the
same ScrapeUrl job used elsewhere if needed, then checked the database again:
// app/Modules/Scraping/Actions/ProductFinder.php
class ProductFinder
{
public function findByUrl(?string $url): ?Product
{
if (! $url) {
return null;
}
$url = urldecode($url);
if ($product = Product::findByUrl($url)) {
return $product;
}
ScrapeUrl::dispatchSync($url, true);
return Product::findByUrl($url);
}
// ...
}
A search result supplied a candidate URL. The configured or generic extractor still had to recover a merchant SKU and
persist a valid product. The local identity key was (domain_id, sku); URL, title, and EAN were mutable observations.
If a result had no price, the finder requested one more synchronous scrape before returning it.
Retailer search with web-search fallback
There were three integration levels:
- a native engine implemented inside or for one retailer, including first-party APIs and locale quirks;
- reusable engines such as configurable
SiteSearchand Algolia; - globally registered paid fallbacks, Bing followed by Serpstack, scoped to the retailer's host.
Only the two paid engines were globally tagged. The many reusable generic classes were building blocks selected by store definitions, not a shotgun list attempted for every retailer. Resolution favored the retailer's own ranking and API before spending money on a broad web search:
// app/Modules/Scraping/Actions/ProductFinder.php
class ProductFinder
{
public function findBySearchEngine(string $term, Domain $domain): ?Product
{
$store = $this->stores->findByDomain($domain);
if (! $store) {
return null;
}
if ($store->hasSearchEngine()) {
$url = $store->getSearchEngine()->search($store, $term);
if ($url && $product = $this->findByUrl($url)) {
$product->scrapeIfPriceMissing();
return $product;
}
}
foreach ($this->searchEngines as $fallback) {
$url = $fallback->search($store, $term);
if ($url && $product = $this->findByUrl($url)) {
$product->scrapeIfPriceMissing();
return $product;
}
}
return null;
}
// ...
}
The highest-confidence fallback was searching the retailer's own site for its SKU and taking the first product result. A SKU is much less ambiguous than a consumer-facing name, and the retailer already maintains the mapping we would otherwise have to reconstruct:
receipt SKU: MTJV3DN/A
→ merchant search endpoint or first-party suggestions API
→ first product URL
→ ordinary Tjommi scraper
→ canonical Product
SiteSearch was the common HTML implementation. It formed a store-relative search URL, made the request with any
search-specific proxy and parameters, treated a redirect to a product page as a direct match, and otherwise returned the
first link matching its configured selector. Algolia performed the same contract against a merchant's public index,
requesting one hit and normalizing a relative path onto the store base URL. Elgiganten's definition called its persisted
GraphQL suggestion operation, filtered product suggestions, and returned the first storefront URL.
Bing searched for the term scoped to site:<merchant-host> and added a market restriction for generic top-level
domains. Serpstack issued a Google query scoped to the same host and selected the first organic result. Neither added a
second internal ranking model. The subsequent scrape was the practical validation.
It also chose a surprisingly stable integration surface. A retailer could break its schema markup, rename every CSS class, or redesign a product page without immediate commercial pain. If searching for a product number stopped finding the product, its own customers and staff complained.
The recurring move was to find the subsystem the retailer itself had to keep working, then use it as an API:
- onsite search became
SKU → product URL; - tracking scripts became
page → product data; - sitemaps became
store → catalog URLs; - templated PDFs became
landmark → field; - canned support email became
message → workflow event.
Search had a different testing profile from receipt parsing. SearchEngineTest contained 1,873 generated live contract
test methods in the snapshot. Each loaded the real store definition, queried its actual engine, and asserted an exact
URL or a stable URL fragment. These tests could reveal that a retailer endpoint had changed, but they were naturally
slow and volatile when rankings changed or a product disappeared; some were explicitly skipped as broken or slow. The
captured email fixtures answered the opposite question: “can we still parse an old layout?” Together they covered live
connectivity and historical compatibility, with different failure modes.
Price event creation
It is tempting to describe the price analysis as AI or anomaly detection. It was neither. The hot path was a deterministic join over price observations, purchased line items, product identity, region, retailer rules, and time.
Each successful scrape appended a timestamped observation:
// app/Models/Product.php
class Product extends BaseModel
{
public function addScrape(float $price, $time = null): Scrape
{
return $this->scrapes()->create([
"domain_id" => $this->domain_id,
"sku" => $this->sku,
"price" => $price,
"scraped_at" => $time ?? now(),
]);
}
// ... product identity and scrape relationships
}
The Scrape::created model hook dispatched GeneratePriceEventsForScrape. The job found the exact product and, when
the EAN was valid, same-EAN products in the same region. Region was an explicit boundary: equal barcodes across
currencies were not comparable observations.
It then looked for receipt items that:
- had a purchase price;
- were still inside the receipt's price-match expiry plus a short slack period;
- belonged to a store configured to generate price events;
- did not already have a price event;
- produced a delta above the configured minimum.
In condensed form:
// app/Jobs/GeneratePriceEventsForScrape.php
class GeneratePriceEventsForScrape implements ShouldQueue
{
public function handle(): void
{
$scrape = Scrape::find($this->scrapeId);
if (! $scrape?->product) return;
$product = $scrape->product;
$productIds = Ean::isValid($product->ean)
? Product::query()
->where("ean", $product->ean)
->whereHas("domain", fn ($query) => $query
->where("region_id", $product->domain->region_id))
->pluck("id")
: [$product->id];
ReceiptItem::query()
->whereIn("product_id", $productIds)
->whereHas("receipt", fn ($query) => $query
->where("expire_date", ">=", now()->subDays(
config("tjommi.price_matching_slack_days"),
))
->whereHas("domain", fn ($domain) => $domain
->where("price_events_enabled", true)))
->whereNotNull("purchase_price")
->whereDoesntHave("priceEvents")
->get()
->each(function (ReceiptItem $item) use ($scrape) {
if ($item->diffAgainst($scrape) >= config("tjommi.minimum_price_diff")) {
$item->addPriceEvent($scrape);
}
});
}
}
The decision matrix was intentionally narrow:
| Check | Accept | Reject |
|---|---|---|
| Product identity | Exact product, or valid EAN within the same region | Name similarity or cross-region EAN |
| Time | Receipt guarantee window plus configured slack is open | Expired receipt |
| Store capability | Domain has price-event generation enabled | Unsupported merchant policy |
| Value | diffAgainst() meets the minimum difference | Lower price is too small to claim economically |
| Idempotency | Receipt item has no existing price event | A claim candidate already exists |
The delta used the effective purchase price, receipt discount, observed price, and quantity. PriceEvent snapshotted
the difference and expected refunded amount, queued a screenshot of the evidence, and created the ticket that would
carry the claim.
That event was intentionally one-shot per receipt item. It prevented duplicate outreach, but it also meant a later, even lower observation did not automatically supersede the first candidate. An older, deprecated job took a different approach: scan all eligible historical observations after purchase and select the minimum. Both versions remain in the repository, making the evolution visible.
The design favored an actionable event over a general time-series engine. We stored price history, but the decision was not "is this movement unusual?" It was "is this exact lower observation eligible, valuable enough, and not already being claimed?"
Ticket creation and reply matching
A Ticket represented one price-match claim, much like a case in a CRM system. It gave Tjommi one place to connect the
customer, retailer, receipt, purchased line item, lower-price observation, and the emails sent or received about that
claim. Its state and JSON metadata recorded what had happened so far.
The PriceEvent explained why a claim could be created: this purchased item had a valid lower-price observation. The
ticket tracked everything that followed: whether Tjommi had contacted the retailer, which conversation contained the
reply, whether the claim needed review, and whether it ended in rejection, money, a gift card, or another payout path.
In domain-driven design terms, Ticket was the aggregate root for that claim. The CRM comparison is also where the name
came from. The record grouped the claim's evidence, conversation, state, and outcome so each automation step operated on
the same case.
The state taxonomy became detailed because false positives had operational causes. Tickets could be pending, approved, rejected, partnership-routed, or invalid for reasons such as wrong product, wrong price, wrong colour or size, used product, out of stock, membership requirements, and other merchant-specific constraints.
The broad categories were stable even as individual state classes changed:
| Category | Representative meaning | Operational consequence |
|---|---|---|
| Pending/open | Candidate exists, claim composed or sent, waiting for the store | Keep monitoring the thread |
| Replied | A likely human response arrived | Put the ticket in an operator's review queue |
| Approved | Store accepted the request as money, gift card, or partner payout | Record the amount and trigger the relevant payout/notification path |
| Rejected | Store explicitly declined the claim | Preserve the reason and close or review |
| Invalid | Evidence or eligibility was wrong | Stop outreach and classify the failure mode |
The invalid-state detail mattered analytically. “Wrong product,” “price already changed back,” “different colour,” and “member-only price” implied different upstream problems. Collapsing them into one failure flag would have made it harder to tell whether product matching, scraping, commercial rules, or retailer interpretation needed repair.
The outbound claim established the anchors needed later. Tjommi sent from the user's connected mailbox, saved the sent
message, tagged it relevant_message and sent_by_tjommi, retained the provider thread ID, and moved the ticket into
an open/pending state.
SendPriceClaim validated the entire relationship before contacting a retailer: price event, receipt, domain, region,
support address, active user mailbox, and a regional claim template all had to exist, and the ticket could not already
have been sent. It then rendered the claim, sent it as the user, and persisted the returned provider message:
// app/Modules/Tickets/Actions/SendPriceClaim.php
class SendPriceClaim
{
public function send(): void
{
$this->validate();
$mailToken = $this->ticket->user->activeMailTokens()->first();
$mail = MailServiceFactory::makeFromToken($mailToken);
$orderRef = trim($this->ticket->receipt->order_reference ?? "") ?: null;
$subject = $orderRef
? Arr::random(["Ordre: {$orderRef}", "Min ordre {$orderRef}", /* ... */])
: Arr::random(["Prisfald?", "Vedr pris", /* ... */]);
$bodyHtml = ClaimGenerator::generateStringFrom(
priceEvent: $this->ticket->priceEvent,
text: ClaimGenerator::genericTemplateForRegion($this->ticket->domain->region),
convertNewlinesToBr: true,
);
$message = PendingMessage::compose()
->setFrom($mailToken->email)
->setTo($this->ticket->domain->customer_support_mail)
->setSubject($subject)
->setBodyHtml($bodyHtml);
$sent = $mail->send($message);
if ($sent) {
$this->ticket->priceEvent->update([
"thread_id" => $sent->getThreadId(),
]);
$stored = StoredEmail::store($mailToken, $sent);
$this->ticket->attachRelevantMessage($stored);
$this->ticket->attachWithTag($stored, Ticket::TAG_SENT_BY_TJOMMI);
// ... connect the tracking pixel
}
if ($this->ticket->state->canTransitionTo(Open::class)) {
$this->ticket->state->transitionTo(Open::class);
}
}
// ... validation plus claim subject/body generation
}
The snippet condenses the subject and body builders. The sent message was stored on the ticket, and its provider thread ID became the strongest key for matching a reply.
Normal reply matching used progressively weaker evidence:
- exact thread ID;
- a thread containing an already relevant message;
- subject relationships;
- known support sender or domain within a plausible response window.
It also distinguished likely autoresponders from human replies using timing and phrases such as "automatic reply" or "automatisk". Multiple deterministic signals compensated for the uncertainty of any one signal.
Reply tagging and ticket automation were separate. FindAndTagTicketReplies decided that a message belonged to a ticket
and whether it looked like a human response. TicketAutomation, when enabled, interpreted the contents of a correlated
reply and performed a known state transition. Keeping those concerns separate allowed an unfamiliar message to appear in
an operator's ticket view without forcing the rule engine to understand it.
TicketAutomation DSL
For low-volume merchants, a human reading the reply was acceptable. For the stores responsible for the most claims, every stable canned response was an opportunity to remove repetitive work.
TicketAutomation was an internal DSL configured beside the store scraper. A Step had a name, predicates, and ordered
actions:
// app/Modules/TicketAutomation/Step.php
class Step
{
protected string $name;
protected array $predicates;
protected array $actions;
public function __construct(string $name, array $predicates = [], array $actions = [])
{
$this->name = $name;
$this->predicates = $predicates;
$this->actions = $actions;
}
public function predicatesFailed(Context $context): bool
{
foreach ($this->predicates as $predicate) {
if ($predicate->passes($context) === false) return true;
}
return false;
}
public function runActions(Context $context): void
{
foreach ($this->actions as $action) {
$action->run($context);
}
}
// ... name(), predicates(), and actions() accessors
}
The predicate vocabulary covered the common protocol signals:
// app/Modules/TicketAutomation/Predicates/
SentFrom
SubjectMatches
SubjectStartsWith
BodyContains / Any / All
BodyContainsOrderReference
BodyContainsProductSku
ReceivedWithin
HasAttachmentStartingWithFilename
ConditionIsMet(callback)
Actions could mark a message relevant, change a badge or note, store metadata, log activity, send a reply, notify the user, transition state, or run arbitrary code.
The Context was the standard library. It exposed the ticket, email, lazy provider message, price event, receipt, line
item, product, user, metadata, string helpers, and lazy attachment content.
JYSK: extracting and reusing a retailer case ID
JYSK's first acknowledgement arrived from a known sender, started with a stable subject, and had to appear within ten minutes of the claim. The matching step extracted the case number and stored it in ticket metadata.
A later message could then require:
// stores/jyskno.php: relationship between two historical steps
sender is JYSK
AND body asks for the receipt
AND ticket metadata contains case_id
AND this message contains that case_id
Future emails were no longer interpreted independently. The engine had learned a piece of durable protocol state from an earlier event.
The approval path demonstrated the value of the callback escape hatch. JYSK sometimes compensated with a gift-card PDF.
The step recognized the sender, subject, and attachment filename, lazily fetched the attachment, invoked the dedicated
gift-card parser, compared the parsed amount with the expected refund, created a first-class Giftcard, and
transitioned the ticket.
The reusable predicates handled recognition, while RunCode kept the gift-card PDF parsing inside the JYSK integration.
Zalando: correlating approval replies and refund amounts
Zalando was the highest-volume claim merchant by a wide margin. Its approval step combined several independent signals:
// stores/zalandodk.php: condensed "approved" automation step
CommonSteps::approved(
predicates: [
new BodyContainsOrderReference(),
new BodyContainsProductSku(),
new BodyContainsAny([
"jeg er glad for at kunne meddele dig",
"Vi har i den forbindelse",
]),
],
actions: [
new MarkAsRelevant(),
new ChangeBadge(
"[AUTOMATION] Approved",
Ticket::BADGE_COLOR_GREEN,
true,
),
new RunCode(function (Context $context) {
$amount = abs(
$context->bodyHtmlAsTextAsStringable()
->after("tilbageført")
->after("modregnet")
->before("kr")
->asNumber()
);
if ($amount <= 0 || $amount > 1000) {
throw new RequiresHumanVerificationException(
"Refund amount is zero or dangerously high",
);
}
$context->priceEvent()->update([
"refunded_amount" => $amount,
]);
}),
new UpdateTicketState(ApprovedMoneyState::class),
],
);
The order reference and SKU correlated the reply with the claim. Stable prose classified it as a known approval message. Landmarks extracted the amount. A hard bound stopped suspicious money from becoming an automated state transition.
Amounts outside the configured range went to human verification instead of updating the refund and ticket state.
The same retailer definition recognized shipment and rejection messages. XXL used a simpler version of the pattern: learn a case ID from a fast acknowledgement, then treat a pair of distinctive support phrases as approval.
Automation deduplication and failure handling
The automation ledger identified an execution by (ticket, stored_email, step) and enforced a composite unique key.
Before running a step, the engine asked the persistence driver whether that exact message had already triggered it.
The execution loop was compact:
// app/Modules/TicketAutomation/TicketAutomation.php
class TicketAutomation
{
public function runForTicket(StoredEmail $storedEmail, Ticket $ticket): void
{
foreach ($this->steps as $step) {
$context = new Context(
ticket: $ticket,
storedEmail: $storedEmail,
step: $step,
);
if ($step->predicatesFailed($context)) continue;
if ($this->persistenceDriver->hasStepBeenTriggered($context)) continue;
try {
$step->runActions($context);
} catch (RequiresHumanVerificationException $exception) {
$this->onHumanVerificationRequired?->__invoke($context, $exception);
} catch (Throwable $exception) {
$this->onStepFailed?->__invoke($context, $exception);
} finally {
$this->onStepTriggered?->__invoke($context);
$this->persistenceDriver->markStepTriggered($context);
}
}
}
// ... run() applies the same interpreter to every candidate ticket
}
Marking the step in finally consumed a matching event even when an action failed or requested human review. This
prevented duplicate replies or gift cards, but a partially completed action sequence could not resume at the failed
action.
Ticket automation rollout and retirement
The git history gives this subsystem a clean boundary. The first implementation appeared in November 2021. It entered the inbox pipeline on November 28. JYSK and XXL rules followed in December; Zalando approval and rejection arrived in June 2022. On September 29, 2022, the default pipeline hook was removed as "no longer relevant."
The final repository still contains the DSL, store rules, persistence drivers, migrations, and tests, but the default
MessageProcessor no longer calls RunTicketAutomation. Several states used by those historical rules are deprecated
after a later ticket-state rewrite.
The remaining code represents a subsystem deployed during that period, not an active default path in the final snapshot.
Analyzing historical retailer emails
The automation rules did not come from retailer documentation. They came from observing the messages retailers actually sent.
Because relevant email was stored before its final purpose was known, we could query sender, host, subject, thread, timestamps, tags, and body text across a large historical corpus. Rule development followed this loop:
query messages from one merchant
→ find a repeated phrase or subject family
→ inspect many examples
→ separate invariant landmarks from variable values
→ encode a predicate and local parser
→ run it against historical messages
→ inspect misses and conflicting matches
→ deploy it to the live pipeline
The queries were rarely elegant:
-- Ad hoc operator query; this was not an application source file.
SELECT id, subject, received_at, body_text, s3_body_html
FROM stored_emails
WHERE from_host LIKE '%zalando%'
AND body_text LIKE '%tilbageført%'
AND received_at >= :from
ORDER BY received_at DESC;
We varied sender, subject, body fragments, and time filters until a stable merchant pattern appeared. These exploratory queries could be expensive on the operational database.
Large HTML bodies were partly moved to object storage so MySQL remained the queryable catalog. In the example query,
s3_body_html is a storage pointer rather than the HTML body.
The rules were manual classifiers built from repeated messages. Merchant, thread, order reference, SKU, phrase, and timing together provided much stronger evidence than any phrase in isolation. Misses and conflicting matches informed the next revision of the rule.
Reporting and claim eligibility
The reporting layer contained daily trends and partitions for users, receipts, receipt sources, products, scrapes, price events, tickets, states, notifications, referrals, and regions. Scheduled commands snapshotted ticket-state inventories, claims initiated, and executive metrics late each day.
The receipt statistics included purchase-day distributions, expired-at-ingestion versus usable receipts, and source breakdowns. Overview reports zero-filled time series and calculated cumulative counts and values. Revenue projections applied the service fee and, in some reports, a hard-coded regional conversion factor. Comments acknowledged that gift-card and physical-payment fee differences were being simplified.
Reports exposed operational patterns but did not make claim decisions. A claim still required exact product identity, an observed lower price, an active guarantee window, merchant support, a minimum amount, and no existing event. Aggregate SQL and corpus exploration were used to understand operations and develop new deterministic rules.
Data lifecycle and privacy controls
Inbox access made the product useful and created its largest responsibility.
The public privacy material described Gmail/Outlook access for automated receipt collection, AWS storage for receipt images, and time-limited access to those assets. The 2019 policy and 2021 web-app policy should be read as dated commitments, not as a substitute for the implementation, but they establish that this was an explicit product surface rather than a hidden integration.
The code enforced several concrete lifecycle boundaries:
- OAuth tokens were encrypted at rest and decrypted when constructing the mail service.
- Disconnecting a mail account revoked the provider token and removed the local token.
- Stored email deletion removed the associated HTML object and attachment records.
- Attachment bytes were not duplicated into every relational record; they could be fetched lazily from the provider.
- A cleanup job removed old irrelevant messages while preserving mail that had become a receipt, parcel, or ticket artifact.
- GDPR export and deletion paths treated receipts, tickets, and related operational data as first-class user data.
Broad ingestion retained messages before every future use was known. This supported later classification and rule discovery, while creating a direct tension with data minimization and purpose limitation.
Scheduled jobs and queue topology
Recurring workloads had different urgency and cost:
| Workload | Shape |
|---|---|
| First inbox scan | Low-priority, months of provider history, chronological processing. |
| Incremental inbox scan | High-priority recent messages, scheduled outside quiet hours. |
| Known receipt extraction | Merchant-specific job on scanner queues. |
| Unknown receipt extraction | Persistent priority table, bounded AI batches, explicit rate-limit recovery. |
| Direct price rechecks | Deduplicated URL batches, twice-daily schedule during active windows. |
| Proxy price rechecks | Individual jobs with provider-aware Redis concurrency limits. |
| Price comparison | Triggered by a new scrape, fan-out to eligible purchased items. |
| Ticket/notification work | State events, email replies, push, tracking, and delayed payment work. |
| Reporting | Late-night snapshot commands and query services. |
Laravel Horizon auto-balanced workers across the queues. Stored email, pending scan, scrape, failed scrape, price event, automation event, and ticket state records showed what had completed before a restart.
Processing-attempt provenance was inconsistent across modules, some failures were reduced to null, and one-shot
automations could partially execute. Several scheduled or legacy paths also remained in the repository after their
operational role changed. The retained records still covered the main restart and debugging paths.
Implementation patterns used across modules
Six implementation choices remained useful across multiple versions of the product.
One receipt model for every input
HTML scanners, PDF landmark parsers, Textract templates, and Davinci all produced Receipt and ReceiptItem records
with source metadata. Product matching, price observations, PriceEvent creation, and ticket handling operated on those
records without checking the original document format.
The shared contract covered the business result rather than every intermediate parser state. Each extractor could use the representation best suited to its input while producing the fields required by the rest of the application.
Store-specific integrations
Each file in stores/ combined a retailer's product fields, search adapter, proxy requirements, receipt support,
price-match policy, and ticket rules. Retailer checks therefore stayed out of the shared scraper, inbox processor, and
ticket pipeline.
The files returned executable PHP configuration. Most values were declarative, while callbacks and custom adapters handled extra requests, cleanup rules, and first-party APIs. This kept simple definitions short without forcing complex stores into a static configuration format.
Scraping DSL for standard product pages
The HTML DSL covered selection, text and attribute access, normalization, and ordered fallbacks for sku, name,
price, brand, and image. A standard retailer needed only a short field definition. A callback handled a field from
an inline script, tracking URL, or unusual page structure without expanding the shared vocabulary.
The PDF, Textract, and TicketAutomation modules used the same approach at different layers. Each provided a small set
of operations for repeated formats and allowed local code for retailer-specific cases.
Deterministic parsers with AI fallback
Known email and PDF formats used explicit parsers backed by captured fixtures. Their selectors, landmarks, and cleanup steps were cheap to run and straightforward to debug. Retaining old fixtures also preserved support for receipt layouts found during the three-month inbox lookback.
Unknown formats entered the prioritized AI queue. OCR supplied text when a PDF had no useful text layer, and Davinci mapped normalized text to the receipt schema. Missing required fields produced an unverified receipt or a failed scan instead of trusted purchase data.
Source evidence and failure records
Stored email bodies, headers, attachments, PDF inputs, and OCR responses made parser failures reproducible. New scanners could run against historical messages without reconnecting the inbox. Failed-scrape records identified the extractor, URL, status code, and captured page involved.
Pending scans, unverified receipts, ticket states, and automation events recorded progress between jobs. Some older
paths still reduced errors to null, but the retained source artifacts covered the main ingestion and automation flows.
Laravel, MySQL, Redis, and queues
Laravel supplied models, commands, pipelines, and queued jobs. MySQL stored receipts, products, observations, events, and tickets. Redis backed Horizon queues and short-lived coordination. Object storage held large source documents.
The database provided durable hand-offs between stages, while queues isolated provider calls, scraping, OCR, model requests, and other slow work from HTTP requests. This stack supported the required workloads without a separate service for each module.
Summary
Tjommi normalized inbox messages, receipt documents, retailer pages, parcel updates, and support replies into relational records. Store definitions and small DSLs handled recurring formats; merchant-specific callbacks and adapters covered exceptions. Captured source data and intermediate records allowed failed work to be inspected and retried.
Known receipt parsers handled established formats, Textract recovered text and layout from scanned documents, and Davinci parsed the remaining text-based formats into the same receipt schema. The receipt annotator let an operator inspect and repair uncertain results without creating a second processing path. Gift cards and parcel events branched from the same stored inbox evidence, while product matching, price-event generation, and ticket automation operated on the shared purchase model.
