Purchase Document Ingestion Troubleshooting
Operational runbook for apps/backend/src/purchase-document-ingestion/.
Use with Purchase Document Ingestion and Purchase Document Extractor Port.
Scope
Primary codepaths:
application/upload-purchase-documents.use-case.tsapplication/extract-purchase-document.use-case.tsapplication/ingest-email-attachment.use-case.tsapplication/post-purchase-document.use-case.tsinfrastructure/extractors/fel-xml-extractor.adapter.tsinfrastructure/purchase-document-storage.service.tsinfrastructure/jobs/*interfaces/purchase-document-ingestion.controller.tsinterfaces/webhooks/purchase-document-email-intake.controller.ts
1) Symptom triage
| Symptom | Likely layer |
|---|---|
Upload 400 Unsupported file type | Controller extension allowlist (XML/PDF only) |
| Upload 400 content does not match declared type | PDF MIME sniff (file-signature.service) |
| Upload succeeds but list never shows the invoice | Receiver NIT mismatch → row deleted during extraction |
Status stuck on received / "Still processing…" | Bull worker down, or persist error that used to escape to Redis failed while the row stayed received |
Status unparsed | PDF uploaded without XML — expected, not a crash |
Status failed immediately | Malformed XML, unrecognized DTE, or persist error |
| Email never creates a draft | Inbox token, SendGrid parse URL, or no XML/PDF attachment |
POST :id/post 400 "Only extracted documents can be posted" | Undismissed review flags (needs_review) |
POST :id/post 400 "Accounts Payable payment method is not configured" | Missing accountsPayable payment method seed |
POST :id/post 400 "At least one inventory line is required" | All lines expense/ignore, or none mapped |
POST :id/post 409 | Already posted (IngestionAlreadyPostedError) |
| Retry extraction 400 | Status is not failed (e.g. unparsed) |
| Preview 404 for a file that uploaded | documentLink missing that kind (xml / pdf) |
| Signed download URL 403 | Clock skew, or GCS_PRIVATE_DOCUMENTS_BUCKET IAM |
2) Capture and extraction checks
Expected upload flow:
- Authenticated
POST /purchase-document-ingestions/upload?businessId=with multipart fieldfiles. - Use case groups by filename stem, stores objects, inserts
ingestionStatus=received. - Bull job
extractwithjobId = ingestionId. - Worker runs
ExtractPurchaseDocumentUseCase(idempotent if status is no longerreceived).
When a draft never appears:
- Confirm the XML Receptor NIT matches
business.taxId. A mismatch deletes the row by design. - Confirm the file was not reported in
data.duplicates(reason: file_hash). - Inspect backend logs for
Extracting purchase document {id}. - If the row exists as
receivedfor more than 10 minutes, the stalled sweep should re-enqueue. After 24 hours it fails withLa extracción no pudo completarse en el tiempo esperado.
When status is failed:
- Parser/type errors will not change on retry unless the extractor code changed.
POST :id/retry-extractionis the path after a parser fix (re-upload is blocked by file-hash / FEL-authorization pairing). - Retry removes any existing Bull job with that
jobIdfirst. If retry appears to no-op, the old failed job was likely still in theremoveOnFail: 500set.
3) Email intake checks
Webhook:
POST /webhooks/sendgrid/inbound-purchase-documents
- Confirm SendGrid Inbound Parse posts to that path (not the outbound Event Webhook). There is no signature header on this product.
- Confirm
Tomatchesinbox-{32 hex}@ingest.flowpos.appfromGET .../inbox-address. - Unknown tokens return 200 with no draft — this is intentional.
- Plain-text emails with no XML/PDF are discarded.
- Controller always returns 200 on processing errors; check logs for
Failed to process inbound purchase-document email. - Unverified From domains still ingest; look for
unverified_senderon the draft after extraction.
4) Posting checks
POST /purchase-document-ingestions/:id/post creates purchase.status = draft. If stock did not move, the purchase has not been submitted yet — that is expected.
Before posting, verify:
ingestionStatus === extracted(dismiss flags first)dteTypeisFACTorFCAMsupplierIdandlocationIdare set (PATCH :id)- at least one line is
inventorywith a product (variant required whenhasVariants) - currency resolves from
currencyId, then business-owned code, then global code - payment method key
accountsPayableexists (getPaymentMethodByKey)
NCRE / NDEB drafts cannot post (PostingUnsupportedDteError).
5) Storage checks
Bucket: GCS_PRIVATE_DOCUMENTS_BUCKET (private, signed-URL only).
Keys: purchase-documents/{businessId}/{uuid}/{filename}.
- Upload throws if the env is unset.
GET :idreplaces keys with 15-minute signed URLs.- In-app preview must use
GET :id/files/{xml|pdf}(authenticated bytes). Do not expose object keys to the PWA.
6) Queue and sweep
| Piece | Behavior |
|---|---|
| Queue name | purchase-document-extraction |
| Job name | extract |
| Job id | ingestion UUID |
| Worker | ExtractPurchaseDocumentProcessor |
| Sweep | @Cron(EVERY_10_MINUTES) via RetryStalledExtractionsProcessor |
| Claim | claimForRetry + FOR UPDATE SKIP LOCKED (multi-instance safe) |
If Cloud Run has no worker processing this queue, every draft stays received until the 24-hour timeout.