ModernVet ETL Sync API

Push records from AWS S3 (or local files) into Odoo 18. Processing is asynchronous โ€” trigger once, poll for result.

Odoo 18.0 Async via Cron API Key Auth AWS S3 + Local
How to Use
Everything you need to get started with the ETL sync module
๐Ÿ“ค AWS Glue / S3 outputs JSONL
โ†’
๐Ÿ“ก POST /api/v1/etl/notifications
โ†’
๐Ÿ“‹ Log created (queued)
โ†’
โš™๏ธ Odoo Cron processes
โ†’
โœ… Records upserted in Odoo

1 Authentication

Pass your API key in either header:

  • X-API-Key: <key>
  • Authorization: Bearer <key>

Keys are managed in Odoo under Settings โ†’ ETL API Keys (model: etl.api.key).

2 Trigger a Sync

POST a JSON body to /api/v1/etl/notifications:

  • data_type โ€” what to sync
  • s3_location โ€” S3 bucket/prefix or local path
  • local: true โ€” read from filesystem

Returns {"status":"queued","log_id":42} immediately.

3 Poll for Status

GET /api/v1/etl/logs/{log_id} to check progress. States: queued โ†’ running โ†’ done / failed.

  • done โ€” check created / updated / skipped / errors counters
  • failed โ€” read error field for exception details

4 S3 Location Resolution

  1. Try s3_location as a direct object key
  2. If not found, list all objects under that prefix
  3. Download and process all matching files together

Multiple files are concatenated before parsing.

5 File Format

Files must be JSON array or JSONL (one object per line). Every record must have id_from_ext as the upsert key.

  • Encoding: UTF-8
  • Extension: .json or .jsonl
  • Max size: limited by available memory

6 Idempotency

All syncs are safe to re-run. Records are matched by id_from_ext:

  • Product / Sales / Invoice โ€” upsert (create or update)
  • Deposit / Deposit Refund โ€” create-only; existing id_from_ext is skipped
  • Loyalty History โ€” create-only; existing id_from_ext is skipped
  • Posted/cancelled invoices โ€” skipped on update attempt

7 AWS S3 Credentials

Configure in Odoo Settings โ†’ Technical โ†’ System Parameters:

  • pet_sync_etl.aws_region โ€” default: ap-southeast-3
  • pet_sync_etl.aws_access_key_id
  • pet_sync_etl.aws_secret_access_key

Leave key/secret empty to use IAM role credentials.

8 Loyalty Journal Config

Required for loyalty journal entries. Configure per company in Settings โ†’ Accounting โ†’ Loyalty Configuration:

  • Invoice journal, payable account, revenue deduction account
  • Non-invoice journal, payable account, revenue deduction account

9 Stuck Jobs & Auto-Reset

If a job stays in running state for more than 2 hours, the next cron run auto-resets it to failed.

Cron runs every ~1 minute and processes one queued log at a time to prevent race conditions.

10 Warehouse id_from_ext

Sales, invoices, and products reference warehouses via warehouse_id (a string). The ETL looks up stock.warehouse by id_from_ext.

Set in Odoo: Inventory โ†’ Configuration โ†’ Warehouses โ€” edit a warehouse and fill in the External ID field.

  • If not found: warehouse field is silently skipped (no error)
  • Analytic account is also resolved from the warehouse and applied to all lines

11 Payment Method Line id_from_ext

Invoice and refund payment_list[].payment_journal_code looks up account.payment.method.line by id_from_ext, scoped to the record's company and payment direction:

  • Invoice (data_type: "invoice") โ€” looks up Incoming Payments method line. Set in Odoo: Accounting โ†’ Configuration โ†’ Journals โ†’ open a journal โ†’ Incoming Payments tab โ†’ fill in External ID.
  • Refund (data_type: "refund") โ€” looks up Outgoing Payments method line. Set in Odoo: Accounting โ†’ Configuration โ†’ Journals โ†’ open a journal โ†’ Outgoing Payments tab โ†’ fill in External ID.
  • Deposit (data_type: "deposit") โ€” looks up Incoming Payments method line (same setup as Invoice).
  • Deposit Refund (data_type: "deposit_refund") โ€” looks up Outgoing Payments method line (same setup as Refund).
  • If not found: that payment entry is skipped with a warning in the log โ€” credit note is posted but unpaid

12 Special Products & Loyalty Program

These Odoo records must exist (created by the module's data files on install):

  • pet_sync_etl.product_global_discount โ€” product used for the global discount line on sales/invoices
  • pet_sync_etl.product_loyalty_redemption โ€” product used for the loyalty redemption line on invoices
  • pet_sync_etl.loyalty_program_modernvet โ€” loyalty program used for card & history sync

If any of these are missing, the related feature is silently skipped (no error, just a warning in the server log).

Quick Start โ€” cURL Examples
๐Ÿ”

Sync invoices from S3

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "invoice",
    "s3_location": "my-bucket/glue-output/invoices/2026-03-25"
  }'
๐Ÿ—‚๏ธ

Test with local file

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "invoice",
    "s3_location": "/home/user/data/invoices.jsonl",
    "local": true
  }'
๐Ÿ“Š

Poll sync status

curl -s http://localhost:8069/api/v1/etl/logs/42 \
  -H "X-API-Key: YOUR_KEY_HERE"
Product โ€” Consumable / Storable
data_type: "product"  ยท  Target model: product.template  ยท  Upsert by id_from_ext
โ„น๏ธ
Upsert behaviour: Records are matched by id_from_ext. On create, name is required. On update, only supplied fields are written. Set deleted_at to any non-empty value to archive the product.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key for upsert
namestringOn CreateProduct name
typeenumOptionalMaps to Odoo type. Values: "consu". Default: "consu"
is_storablebooleanOptionalSet true to enable inventory tracking
list_pricenumberOptionalSales price
standard_pricenumberOptionalCost price
default_codestringOptionalInternal reference
barcodestringOptionalStored in barcode_from_ext on the product template
product_sizestringOptionalSize/weight label (custom field from pet_stock)
brand_idstringOptionalBrand name โ€” searched or created in product.brand
descriptionstringOptionalInternal notes
description_salestringOptionalCustomer-facing description
description_purchasestringOptionalVendor-facing description
activebooleanOptionalDefault true. Set false to archive.
deleted_atstringOptionalAny non-empty value archives the product (active=false)
disabled_atstringOptionalAny non-empty value sets sale_ok=false
weightnumberOptionalProduct weight (kg)
volumenumberOptionalProduct volume (mยณ)
invoice_policyenumOptional"order" (invoice immediately) or "delivery" (after delivery). Default: "delivery"
purchase_methodenumOptional"purchase" or "receive"
warehouse_idstringOptionalid_from_ext of stock.warehouse
categ_idobjectOptionalProduct category โ€” see Category Schema below
x_uom_category_idarrayOptionalUOM units list. Replaces all existing units in the product's private UOM category โ€” see UOM Schema

categ_id โ€” Product Category

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key
namestringRequiredCategory name
parent_idstring | objectOptionalString = id_from_ext of parent, or nested Category object (recursive)
property_cost_methodenumOptional"standard", "average", "fifo". Default: "standard"
property_valuationenumOptional"manual_periodic" or "real_time". Default: "manual_periodic"
deleted_atstringOptionalStored in deleted_at_ext on the category for informational purposes

x_uom_category_id โ€” UOM Units

FieldTypeRequiredDescription
namestringRequiredUnit name e.g. "Unit", "Box(12)"
uom_typeenumRequired"sub" = reference unit (1:1 ratio), "main" = larger packaging unit. Only one "sub" per category.
rationumberOptionalConversion factor relative to the reference unit

Example JSONL Record

{
  "id_from_ext": "CONSU-001",
  "name": "Royal Canin Maxi Adult 10kg",
  "type": "consu",
  "list_price": 450000.0,
  "default_code": "RC-MAXI-10K", // ETL never send
  "barcode": "7612938888001", // stored in barcode_from_ext field
  "product_size": "10kg", // ETL never send
  "brand_id": "Royal Canin", // ETL never send
  "categ_id": {
    "id_from_ext": "CAT-DOG-FOOD",
    "name": "Dog Food",
    "parent_id": {
      "id_from_ext": "CAT-PET-FOOD",
      "name": "Pet Food",
      "property_cost_method": "average",
      "property_valuation": "real_time"
    },
    "property_cost_method": "average",
    "property_valuation": "real_time"
  },
  "is_storable": false, // if true โ†’ tracking = 'serial' (inventory tracked by serial number)
  "invoice_policy": "delivery",
  "purchase_method": "receive",
  "x_uom_category_id": [
    {"name": "Unit", "uom_type": "sub", "ratio": 1.0},
    {"name": "Box(4)", "uom_type": "main", "ratio": 4.0}
  ], // uom matched by name within product's private UOM category
  "description": "Internal notes text"
}
Product โ€” Service
data_type: "product"  ยท  Target model: product.template  ยท  Upsert by id_from_ext
๐Ÿ’ก
Service products are not tracked in inventory. invoice_policy defaults to "order" (invoiceable immediately after sale confirmation).

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key for upsert
namestringOn CreateProduct name
typeenumOptionalMust be "service"
list_pricenumberOptionalSales price
standard_pricenumberOptionalCost price
default_codestringOptionalInternal reference
descriptionstringOptionalInternal notes
description_salestringOptionalCustomer-facing description
activebooleanOptionalDefault true
deleted_atstringOptionalAny non-empty value archives the product
statusstringOptional"enabled" โ†’ sale_ok=true; "disabled" โ†’ sale_ok=false
invoice_policyenumOptional"order" (default) or "delivery"
categ_idobjectOptionalSame structure as Consumable category

Example JSONL Record

{
  "id_from_ext": "SVC-001",
  "name": "Basic Health Checkup",
  "type": "service",
  "list_price": 150000.0,
  "default_code": "SVC-REF", // ETL never send
  "additional_products": [
    {
      "id_from_ext": "CONSU-001",
      "quantity": 1.0,
      "uom_ext_id": "Unit"
    }
  ],
  "categ_id": {
    "id_from_ext": "CAT-VET-EXAM",
    "name": "Veterinary Examination",
    "parent_id": {
      "id_from_ext": "CAT-VET-SVC",
      "name": "Veterinary Services"
      "property_cost_method": "average", // ETL never send
      "property_valuation": "real_time"  // ETL never send
    },
    "property_cost_method": "average", // ETL never send
    "property_valuation": "real_time"  // ETL never send
  },
  "invoice_policy": "order",
  "description": "Internal notes text",
  "deleted_at": "2026-01-02 12:03:11.000000", // non-empty โ†’ archives product (active=false)
  "disabled_at": "...", // ETL never send
  "warehouse_id": "3332",
  "status": "enabled", // "enabled" โ†’ sale_ok=true; "disabled" โ†’ sale_ok=false
  "barcode": "" // stored in barcode_from_ext field
}
Sales Order
data_type: "sales" or "sale"  ยท  Target model: sale.order  ยท  Upsert by id_from_ext
โš ๏ธ
Update restriction: Only draft orders can be updated. Confirmed (sale) or cancelled orders are skipped on re-sync. Pass "state": "sale" to auto-confirm after upsert.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key for upsert
partner_idobjectRequiredObject with id_from_ext of res.partner
partner_id.id_from_extstringRequiredExternal ID of the customer partner
date_orderstringOptionalFormat: YYYY-MM-DD HH:MM:SS
stateenumOptional"draft", "sale", or "done". Values "sale"/"done" trigger auto-confirm.
client_order_refstringOptionalCustomer reference number
refstringOptionalStored as client_order_ref
notestringOptionalInternal note / terms
pet_namestringOptionalPatient/pet name (custom field)
warehouse_idstringOptionalid_from_ext of stock.warehouse
global_discount_amountnumberOptionalAdds a negative-price Global Discount line to the order
loyalty_redemption_amountnumberOptionalAdds a negative-price Loyalty Redemption line to the order
namestringOptionalSO number override e.g. SO-2026-001. Set on create only; bypasses sequence constraint.
order_linearrayOptionalLines are fully replaced when present. See Order Line below.

order_line โ€” Sale Order Line

FieldTypeRequiredDescription
id_from_extstringOptionalUsed to link invoice lines to SO lines
product_idstringOptionalid_from_ext of product.template
product_uom_qtynumberOptionalQuantity. Default: 1.0
price_unitnumberOptionalUnit price
discountnumberOptionalDiscount percentage (0โ€“100). Ignored when discount_fixed is set.
discount_fixednumberOptionalFixed-amount discount per line (IDR). When set, overrides discount %.
namestringOptionalLine description
product_uomstringOptionalUOM name within the product's private UOM category

Example JSONL Record

{
  "id_from_ext": "SO-2026-001",
  "name": "SO-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso",
    "street": "Jl. Sudirman 1",
    "street2": "Lt. 5",
    "city": "Jakarta",
    "zip": "12190",
    "phone": "021-5551234",
    "mobile": "081234567890",
    "email": "budi@example.com",
    "ref": "MBR-001",
    "comment": "VIP customer"
  },
  "pet_name": "Max",
  "date_order": "2026-03-25 09:00:00",
  "warehouse_id": "WH-00001",
  "order_line": [
    {
      "id_from_ext": "SOL-001-01",
      "product_id": "CONSU-001",
      "name": "Royal Canin Maxi Adult 10kg",
      "product_uom": "Unit",
      "product_uom_qty": 2.0,
      "price_unit": 450000.0,
      "discount_fixed": 5000, // fixed price discount
      "discount": 10, // percentage discount
      "price_subtotal": 805000.0 // Verification only โ€” Odoo recomputes this value
    },
    {
      "id_from_ext": "SOL-001-02",
      "product_id": "SVC-001",
      "name": "Basic Health Checkup",
      "product_uom": "Box(4)",
      "product_uom_qty": 1.0,
      "price_unit": 150000.0,
      "discount_fixed": 5000,
      "discount": 10,
      "price_subtotal": 130000.0 // Verification only โ€” Odoo recomputes this value
    }
  ],
  "note": "Sales from WH-00001",
  "global_discount_amount": 1000000, // adds Global Discount line with this amount as negative price
  "amount_untaxed": 1000000, // Verification only โ€” Odoo recomputes this value
  "amount_tax": 0, // Verification only โ€” Odoo recomputes this value
  "amount_total": 1000000 // Verification only โ€” Odoo recomputes this value
}
Invoice
data_type: "invoice" or "invoices"  ยท  Target model: account.move  ยท  Upsert by id_from_ext
โ„น๏ธ
Auto-post flow: The ETL always posts the invoice. Steps:
  1. Add voucher line if voucher_amount is set โ€” negative line using voucher_product_id; label = voucher_code
  2. Add loyalty redemption line if point_redeem_idr > 0
  3. Add global discount line if global_discount < 0
  4. Post the invoice
  5. Create loyalty card history + journal entries
  6. Register payments from payment_list:
    • If entry has payment_journal_code โ†’ creates account.payment (inbound); actual amount = payment_list.amount โˆ’ loyalty redemption line โˆ’ global discount line (voucher is already pre-deducted in amount). If the payment amount exceeds the invoice total and the payment journal creates a JE immediately (non-bank journal), the excess is automatically split into customer_deposit_account_id โ€” see the Overpayment note below.
    • If entry has payment_id_from_ext โ†’ applies an existing deposit credit via a transfer journal entry (no new payment created)

Invoice Header Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key for upsert
partner_idobjectRequiredCustomer โ€” matched by id_from_ext; auto-created if not found (requires name)
partner_id.id_from_extstringRequiredExternal ID of customer
partner_id.namestringOptionalUsed to auto-create partner if not found
partner_id.emailstringOptionalPartner email (on auto-create)
partner_id.phonestringOptionalPartner phone (on auto-create)
namestringOptionalInvoice number e.g. TRX-2026-00001. Set on create only.
move_typeenumOptional"out_invoice" (default) or "in_invoice". For credit notes use data_type: "refund" instead.
invoice_datedateOptionalFormat: YYYY-MM-DD
invoice_date_duedateOptionalDue date. Format: YYYY-MM-DD
refstringOptionalSource document reference
narrationstringOptionalInternal notes / terms
pet_namestringOptionalPatient/pet name (custom field)
warehouse_idstringOptionalid_from_ext of stock.warehouse. Sets analytic_distribution (100%) from warehouse.analytic_account_id on: invoice lines, voucher line, loyalty redemption line, receivable/payable JE lines (after post), payment JE lines (when JE exists), and loyalty earn JE.
global_discount_amountnumberOptionalPositive value e.g. 25000. Adds a Global Discount line (stored as negative price). Also accepted as global_discount (negative). Amount deducted from payment.
membership_discountnumberOptional Positive value e.g. 250000. Free-consultation discount from mobile apps. Only applies to out_invoice.

Two source formats accepted:
(1) payment_list.amount = full invoice total โ€” code auto-subtracts membership_discount from the payment amount before the wizard, so the write-off difference is non-zero.
(2) payment_list.amount = actual collected (already pre-reduced by discount) โ€” used as-is; write-off = invoice_residual โˆ’ payment_list.amount.

In both cases the resulting payment JE is:
 Dr Bank                                    actual collected amount
 Dr Membership Discount Account   membership_discount
  Cr AR (full invoice residual)

Re-sync fallback: if payments are already registered (re-sync scenario), a separate write-off JE is posted using the fixed membership_discount amount and reconciled against the remaining open AR line:
 Dr Membership Discount Account   membership_discount
  Cr AR (open receivable on invoice)

Idempotent: the write-off (both primary and fallback) is skipped if invoice.amount_residual โ‰ค 0.01 โ€” i.e. the invoice is already fully settled.

Requires Settings โ†’ Accounting โ†’ Membership Purchase โ†’ Membership Discount Account.
voucher_amountnumberOptionalVoucher deduction amount. Adds a negative invoice line using voucher_product_id (company config). The payment_list.amount is already pre-deducted by the voucher โ€” no further adjustment to payment.
voucher_codestringOptionalVoucher code โ€” used as the line description on the voucher deduction line.
point_earningnumberOptionalLoyalty points earned. Stored on invoice; triggers loyalty earn history + misc JE only when payment_list is present.
point_earning_idrnumberOptionalIDR monetary value of earned points. Used as misc JE amount; stored in issued_idr_ext on loyalty history. Analytic distribution is set from warehouse_id.analytic_account_id.
point_redeemnumberOptionalLoyalty points redeemed. Creates loyalty redeem history. Adds Loyalty Redemption line to invoice.
point_redeem_idrnumberOptionalIDR value of redeemed points. Used as redemption line amount (price_unit = โˆ’point_redeem_idr). Stored in used_idr_ext.
invoice_line_idsarrayOptionalInvoice lines โ€” upserted by id_from_ext. Lines absent from incoming data are deleted.
payment_listarrayOptionalPayments to register. Only applied when state=posted and invoice has no existing payments.
amount_untaxed_in_currency_signednumberOptionalValidation only โ€” mismatch logged in manual_check_missmatch
amount_totalnumberOptionalValidation only
โš™๏ธ
Loyalty Point Analytic Flow (Invoice-related): When point_earning / point_earning_idr are set and payment_list is present, a misc journal entry is created after payment registration:
  • Earn JE: Debit Revenue Deduction, Credit Payable (amount = point_earning_idr)
  • Analytic distribution on both JE lines is sourced from warehouse_id.analytic_account_id (set on the warehouse record under Inventory โ†’ Configuration โ†’ Warehouses โ†’ Analytic Account)
  • Invoice lines, discount line, redeem line, and receivable line all also receive the same warehouse analytic
Config required: Settings โ†’ Accounting โ†’ Loyalty Configuration โ†’ Invoice section (journal + payable account + revenue deduction account).
๐Ÿ’ฐ
Overpayment โ†’ Customer Deposit (ETL invoices only)
When payment_list[].amount exceeds the invoice total, the excess is recorded as a customer deposit instead of leaving an outstanding AR credit on the payment.
  • The payment journal entry is modified from Dr Bank / Cr AR (full amount) to Dr Bank / Cr AR (invoice amount) / Cr Customer Deposit (excess).
  • The AR credit line is reduced to exactly the invoice amount and reconciled with the invoice receivable line. The invoice is fully paid.
  • Requires customer_deposit_account_id to be configured in Settings โ†’ Accounting โ†’ Customer Deposit Account. If not configured, a warning is logged and the excess remains as outstanding AR credit on the payment.
  • Only works when the payment journal creates a JE immediately (i.e., non-bank journals such as cash or custom). Bank journals in Odoo 18 defer JE creation to bank reconciliation โ€” in that case the split is skipped and a warning is logged.

invoice_line_ids โ€” Invoice Line

FieldTypeRequiredDescription
id_from_extstringOptionalMatch key for upsert; also used to link to sale.order.line
product_idstringOptionalid_from_ext of product.template
namestringOptionalLine description
quantitynumberOptionalQuantity. Default: 1.0
price_unitnumberOptionalUnit price
discountnumberOptionalDiscount percentage (0โ€“100). Ignored when discount_fixed is set.
discount_fixednumberOptionalFixed-amount discount per line (IDR). When set, overrides discount %.
price_subtotalnumberOptionalValidation only โ€” mismatch logged

payment_list โ€” Payment Entry

๐Ÿ’ณ
Each entry uses either payment_journal_code (new bank/cash payment) or payment_id_from_ext (apply existing deposit credit) โ€” not both. Actual registered amount for bank/cash payments = amount โˆ’ sum(loyalty redemption line + global discount line) read from the posted invoice. The amount is already pre-deducted by any voucher_amount โ€” no further adjustment for voucher. If membership_discount is set on the invoice, the first payment entry will include a write-off of that amount to membership_discount_account_id โ€” the amount here should already be the net collected amount. The original amount is always stored in account.payment.paid_amount_ext.
FieldTypeRequiredDescription
payment_journal_codestringConditionalid_from_ext of an inbound account.payment.method.line. Creates a new account.payment. Mutually exclusive with payment_id_from_ext.
payment_id_from_extstringConditionalid_from_ext of an existing posted deposit (account.payment, inbound). Applies the deposit credit to this invoice via a transfer journal entry. Mutually exclusive with payment_journal_code.
amountnumberRequiredFull amount from external system. Also accepted as paid_amount. Stored as-is in paid_amount_ext. Actual registered amount is reduced by deductions (bank/cash payments only).
datedateOptionalPayment date. Also accepted as payment_date. Format: YYYY-MM-DD
memostringOptionalPayment note / communication
payment_type_name_extstringOptionalStored in account.payment.payment_type_name_ext for external tracking (bank/cash payments only)
payment_type_card_brand_extstringOptionalCard brand stored in account.payment.payment_type_card_brand_ext (bank/cash payments only)

Example JSONL Record โ€” Full Invoice with Loyalty & Payment

{
  "id_from_ext": "INV-2026-001",
  "name": "TRX-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso",
    "street": "Jl. Sudirman 1",
    "street2": "Lt. 5",
    "city": "Jakarta",
    "zip": "12190",
    "phone": "021-5551234",
    "mobile": "081234567890",
    "email": "budi@example.com",
    "ref": "MBR-001",
    "comment": "VIP customer"
  },
  "pet_name": "Max",
  "invoice_date": "2026-03-25",
  "invoice_date_due": "2026-03-25",
  "ref": "FROM SALES OF SO-2026-001",
  "warehouse_id": "WH-00001",
  "invoice_line_ids": [
    {
      "id_from_ext": "INV-001-L01",
      "product_id": "SVC-001",
      "product_uom": "Unit",
      "name": "Basic Health Checkup",
      "quantity": 1.0,
      "price_unit": 500000.0,
      "discount_fixed": 5000, // fixed price discount
      "discount": 10, // percentage discount
      "price_subtotal": 445000.0 // Verification only โ€” Odoo recomputes this value
    }
  ],
  "narration": "Invoice from WH-00001",
  "global_discount_amount": 25000, // adds Global Discount line with negative price; deducted from payment
  "voucher_amount": 10000, // adds Voucher line with negative price; payment_list.amount already pre-deducted
  "voucher_code": "VOUCH-2026-001", // label on the voucher line
  "amount_untaxed_in_currency_signed": 500000, // Verification only โ€” Odoo recomputes this value
  "amount_tax_in_currency_signed": 0, // Verification only โ€” Odoo recomputes this value
  "amount_total": 425000, // Verification only โ€” Odoo recomputes this value
  "payment_list": [
    {
      "payment_journal_code": "PMETHOD-EXT-0001", // id_from_ext of account.payment.method.line
      "amount": 500000.0, // stored in paid_amount_ext; actual payment deducts loyalty + discount
      "date": "2026-03-25",
      "memo": "Bank Transfer",
      "payment_type_name_ext": "Bank Transfer",
      "payment_type_card_brand_ext": "Visa"
    }
  ],
  "amount_due_in_currency_signed": 425000, // Verification only โ€” Odoo recomputes this value
  "point_earning": 850,
  "point_redeem": 100,
  "point_earning_idr": 85000,
  "point_redeem_idr": 50000
}
๐Ÿงฎ
In this example: actual payment = 500000 โˆ’ 50000 (redeem) โˆ’ 25000 (discount) = 425000. The original 500000 is saved in paid_amount_ext. The 10000 voucher is already reflected in the 500000 amount โ€” it only adds a negative invoice line, no further payment deduction.
Refund / Credit Note
data_type: "refund" or "refunds"  ยท  Target model: account.move (out_refund)  ยท  Create-only by id_from_ext
โš ๏ธ
Create-only: Records where id_from_ext already exists are silently skipped โ€” no update is performed. reversed_entry_id must reference an invoice that exists and is fully paid (in_payment or paid), otherwise the record is skipped.
โ„น๏ธ
Same JSON format as invoice. move_type is not required โ€” data_type: "refund" always creates out_refund. When state=posted, the ETL will:
  1. Post the credit note
  2. Register outgoing payments from payment_list (method line matched by id_from_ext + payment_type=outbound)
  3. Deduct earned loyalty points (point_earning) โ€” JE: Debit Payable, Credit Revenue Deduction
  4. Restore redeemed loyalty points (point_redeem) โ€” JE: Debit Revenue Deduction, Credit Payable

Header Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredMatch key for upsert
partner_idobjectRequiredCustomer โ€” matched by id_from_ext; auto-created if not found (requires name)
partner_id.id_from_extstringRequiredExternal ID of customer
partner_id.namestringOptionalUsed to auto-create partner if not found
namestringOptionalCredit note number e.g. RINV/2026/RF/001. Set on create only.
invoice_datedateOptionalFormat: YYYY-MM-DD
invoice_date_duedateOptionalDue date. Format: YYYY-MM-DD
refstringOptionalSource document reference
narrationstringOptionalInternal notes
pet_namestringOptionalPatient/pet name (custom field)
warehouse_idstringOptionalid_from_ext of stock.warehouse. Sets analytic_distribution (100%) from warehouse.analytic_account_id on: all credit note lines (product, discount, redemption), receivable/payable JE lines (after post), payment JE lines (when JE exists), and loyalty earn-deduction / redeem-restoration JEs.
reversed_entry_idstringRequiredid_from_ext of the original invoice being reversed. If not found in Odoo, the record is skipped.
global_discount_amountnumberOptionalPositive value. Adds a Global Discount line. Amount deducted from payment.
point_earningnumberOptionalPoints originally earned on the invoice โ€” will be deducted. JE: Debit Payable, Credit Revenue Deduction.
point_earning_idrnumberOptionalIDR value of earn deduction. Used as JE amount. Analytic from warehouse_id.analytic_account_id.
point_redeemnumberOptionalPoints originally redeemed on the invoice โ€” will be restored. JE: Debit Revenue Deduction, Credit Payable.
point_redeem_idrnumberOptionalIDR value of redeem restoration. Used as JE amount. Analytic from warehouse_id.analytic_account_id.
invoice_line_idsarrayOptionalCredit note lines โ€” upserted by id_from_ext.
payment_listarrayOptionalOutgoing payments to register. Only applied when state=posted and credit note has no existing payments.

invoice_line_ids โ€” Credit Note Line

FieldTypeRequiredDescription
id_from_extstringOptionalMatch key for upsert
product_idstringOptionalid_from_ext of product.template
namestringOptionalLine description
quantitynumberOptionalQuantity. Default: 1.0
price_unitnumberOptionalUnit price
discountnumberOptionalDiscount percentage (0โ€“100)
discount_fixednumberOptionalFixed amount discount
price_subtotalnumberOptionalValidation only โ€” mismatch logged

payment_list โ€” Outgoing Payment Entry

๐Ÿ’ณ
payment_journal_code must match id_from_ext on an Outgoing account.payment.method.line. Set in Odoo: Accounting โ†’ Configuration โ†’ Journals โ†’ Outgoing Payments tab โ†’ External ID column.
FieldTypeRequiredDescription
payment_journal_codestringRequiredid_from_ext of outgoing account.payment.method.line. Determines both the journal and payment method.
amountnumberRequiredFull refund amount. Also accepted as paid_amount. Stored as-is in paid_amount_ext.
datedateOptionalPayment date. Also accepted as payment_date. Format: YYYY-MM-DD
memostringOptionalPayment note / communication
payment_type_name_extstringOptionalStored in account.payment.payment_type_name_ext
payment_type_card_brand_extstringOptionalCard brand stored in account.payment.payment_type_card_brand_ext

Example JSONL Record โ€” Full Refund with Loyalty & Outgoing Payment

{
  "id_from_ext": "REFUND-2026-001",
  "name": "RINV/2026/RF/001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso",
    "street": "Jl. Sudirman 1",
    "city": "Jakarta",
    "email": "budi@example.com"
  },
  "pet_name": "Max",
  "invoice_date": "2026-03-25",
  "invoice_date_due": "2026-03-25",
  "ref": "REFUND-SO-2026-001",
  "warehouse_id": "WH-00001",
  "reversed_entry_id": "INV-2026-001", // id_from_ext of original invoice
  "invoice_line_ids": [
    {
      "id_from_ext": "RSOL-001-01",
      "product_id": "CONSU-001",
      "product_uom": "Unit",
      "name": "Royal Canin Maxi Adult 10kg",
      "quantity": 2.0,
      "price_unit": 450000.0,
      "discount_fixed": 5000,
      "discount": 0,
      "price_subtotal": 890000.0 // Verification only
    }
  ],
  "narration": "Refund of TRX-2026-001",
  "global_discount_amount": 35000,
  "payment_list": [
    {
      "payment_journal_code": "CASH-EXT", // id_from_ext of account.payment.method.line with payment_type=outbound
      "amount": 1035000.0,
      "date": "2026-03-25",
      "memo": "Refund Bank Transfer SO-2026-001",
      "payment_type_name_ext": "Bank Transfer",
      "payment_type_card_brand_ext": ""
    }
  ],
  "point_earning": 2000, // points to deduct (originally earned on invoice)
  "point_earning_idr": 20000,
  "point_redeem": 5000, // points to restore (originally redeemed on invoice)
  "point_redeem_idr": 50000
}
Customer Deposit
data_type: "deposit" or "deposits"  ยท  Target model: account.payment (inbound)  ยท  Create-only by id_from_ext
โ„น๏ธ
What this does: Creates a standalone inbound payment posted against a customer deposit account (not the default receivable). The deposit is then applied to an invoice by referencing its id_from_ext in the invoice's payment_list[].payment_id_from_ext.

Accounting flow:
On deposit creation: Debit Bank/Cash โ†’ Credit Customer Deposit Account
On invoice application: Transfer JE: Debit Customer Deposit Account โ†’ Credit Receivable, reducing invoice residual
โš ๏ธ
Create-only: Records where id_from_ext already exists are skipped (counted as updated). To apply a deposit to an invoice, use payment_id_from_ext in the invoice's payment_list โ€” see Invoice schema.

Prerequisites: Company must have Customer Deposit Account and Customer Deposit Transfer Journal configured in Settings โ†’ Accounting โ†’ Customer Deposit. The deposit account must have reconcile = True (auto-set by the ETL on first use).

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency key. Already-existing records are skipped.
amountnumberRequiredDeposit amount
payment_journal_codestringRequiredid_from_ext of an Inbound account.payment.method.line. Determines journal and payment method.
partner_idobjectOptionalCustomer โ€” matched by id_from_ext; auto-created if not found (requires name)
partner_id.id_from_extstringOptionalExternal ID of customer
namestringOptionalVoucher name โ€” overrides auto-generated JE name
datedateOptionalPayment date. Format: YYYY-MM-DD
memostringOptionalPayment communication / reference
warehouse_idstringOptionalid_from_ext of stock.warehouse. Sets analytic_distribution (100%) from warehouse.analytic_account_id on all payment JE lines (deposit-account line + bank/cash line) when the JE exists.
payment_type_name_extstringOptionalStored in account.payment.payment_type_name_ext
payment_type_card_brand_extstringOptionalCard brand stored in account.payment.payment_type_card_brand_ext

Applying a Deposit to an Invoice โ€” payment_list[].payment_id_from_ext

๐Ÿ”—
To apply a deposit to an invoice, include a payment_list entry with payment_id_from_ext (instead of payment_journal_code) in the invoice JSON. The deposit must exist and be posted.
FieldTypeRequiredDescription
payment_id_from_extstringRequiredid_from_ext of the account.payment deposit to apply
amountnumberOptionalAmount to apply. Capped to: min(deposit available balance, invoice residual). If omitted, uses full available balance.
datedateOptionalTransfer JE date
memostringOptionalTransfer JE reference note
๐Ÿ”„
Idempotent: If the deposit credit line is already fully reconciled, the application is silently skipped โ€” safe to re-sync.

Example โ€” Deposit then Invoice with Deposit Applied

Step 1 โ€” Create deposit (data_type: "deposit")
{
  "id_from_ext": "DEP-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso"
  },
  "warehouse_id": "3296",
  "date": "2026-04-01",
  "amount": 500000.0,
  "memo": "Advance deposit April 2026",
  "payment_journal_code": "PMETHOD-EXT-0001", // id_from_ext of inbound account.payment.method.line
  "payment_type_name_ext": "BCA Transfer"
}
Step 2 โ€” Invoice that consumes the deposit (data_type: "invoice")
{
  "id_from_ext": "INV-2026-DEP-001",
  "partner_id": {"id_from_ext": "CUST-001"},
  "invoice_date": "2026-04-05",
  "warehouse_id": "3296",
  "invoice_line_ids": [
    {
      "id_from_ext": "IDEP-L01",
      "product_id": "SVC-001",
      "quantity": 2.0,
      "price_unit": 150000.0
    }
  ],
  "payment_list": [
    {
      "payment_id_from_ext": "DEP-2026-001", // id_from_ext of deposit โ€” triggers transfer JE
      "amount": 300000.0, // partial application; capped to min(deposit balance, invoice residual)
      "date": "2026-04-05",
      "memo": "Apply deposit DEP-2026-001"
    }
  ]
}
๐Ÿงฎ
Invoice total = 300,000. Deposit applies 300,000 โ†’ amount_residual = 0, payment_state = in_payment.
Deposit still has 200,000 remaining balance (500,000 โˆ’ 300,000) โ€” can be applied to future invoices.
Customer Deposit Refund
data_type: "deposit_refund" or "deposit_refunds"  ยท  Target model: account.payment (outbound)  ยท  Create-only by id_from_ext
โ„น๏ธ
What this does: Creates a standalone outbound payment posted against the customer deposit account, then directly reconciles it against the original inbound deposit's credit line on that same account โ€” no transfer JE needed.

Accounting flow:
Original deposit (inbound): Debit Bank/Cash โ†’ Credit Customer Deposit Account
Deposit refund (outbound): Debit Customer Deposit Account โ†’ Credit Bank/Cash
Reconcile: Both Customer Deposit Account lines cancel each other out
โš ๏ธ
Create-only: Records where id_from_ext already exists are skipped (counted as updated).

original_deposit_id not found: Record is skipped (not errored) โ€” the original inbound deposit must exist and be posted before syncing its refund.

Prerequisites: Company must have Customer Deposit Account configured in Settings โ†’ Accounting โ†’ Customer Deposit. The payment_journal_code must reference an Outgoing Payments method line.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency key. Already-existing records are skipped.
amountnumberRequiredRefund amount
payment_journal_codestringRequiredid_from_ext of an Outgoing account.payment.method.line. Determines journal and payment method.
original_deposit_idstringRequiredid_from_ext of the original inbound deposit to reconcile against. Record is skipped if not found in Odoo.
partner_idobjectOptionalCustomer โ€” matched by id_from_ext; auto-created if not found (requires name)
partner_id.id_from_extstringOptionalExternal ID of customer
namestringOptionalVoucher name โ€” overrides auto-generated JE name
datedateOptionalPayment date. Format: YYYY-MM-DD
memostringOptionalPayment communication / reference
warehouse_idstringOptionalid_from_ext of stock.warehouse. Sets analytic_distribution (100%) from warehouse.analytic_account_id on all payment JE lines (deposit-account line + bank/cash line) when the JE exists.
payment_type_name_extstringOptionalStored in account.payment.payment_type_name_ext
payment_type_card_brand_extstringOptionalCard brand stored in account.payment.payment_type_card_brand_ext

Example โ€” Full Deposit Lifecycle (Received โ†’ Refunded)

Step 1 โ€” Create deposit (data_type: "deposit")
{
  "id_from_ext": "DEP-2026-001",
  "name": "DEP-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso"
  },
  "warehouse_id": "3296",
  "date": "2026-04-01",
  "amount": 500000.0,
  "memo": "Advance deposit April 2026",
  "payment_journal_code": "PMETHOD-EXT-INBOUND-001", // id_from_ext of inbound account.payment.method.line
  "payment_type_name_ext": "BCA Transfer"
}
Step 2 โ€” Refund the deposit (data_type: "deposit_refund")
{
  "id_from_ext": "DEP-REF-2026-001",
  "name": "DEP-REF-2026-001",
  "partner_id": {"id_from_ext": "CUST-001"},
  "warehouse_id": "3296",
  "date": "2026-04-10",
  "amount": 500000.0,
  "memo": "Full deposit refund - customer cancelled",
  "payment_journal_code": "PMETHOD-EXT-OUTBOUND-001", // id_from_ext of outbound account.payment.method.line
  "payment_type_name_ext": "BCA Transfer",
  "original_deposit_id": "DEP-2026-001" // id_from_ext of the original inbound deposit
}
๐Ÿงฎ
After Step 2: the 500,000 debit line (refund) reconciles with the 500,000 credit line (original deposit) on the Customer Deposit Account โ€” both lines are marked reconciled = True.
Partial refund is also supported: send amount less than the original deposit; the remaining balance stays unreconciled and can be applied to future invoices or refunded later.
Loyalty History
data_type: "loyalty_history"  ยท  Target models: loyalty.card + loyalty.history  ยท  Create-only by id_from_ext
โš ๏ธ
Create-only: Records where id_from_ext already exists are silently skipped โ€” no update is performed. This prevents duplicate journal entries on re-sync.
โš™๏ธ
Journal entry logic: Requires company's non-invoice loyalty journal to be configured in Settings โ†’ Accounting โ†’ Loyalty Configuration.
  • issued > 0 โ†’ Earn JE: Debit Revenue Deduction account, Credit Payable account
  • used > 0 โ†’ Redeem JE: Debit Payable account, Credit Revenue Deduction account
  • Analytic distribution: set from Settings โ†’ Accounting โ†’ Loyalty Configuration โ†’ Analytic Account (Non-Invoice Loyalty) (company.loyalty_non_invoice_analytic_account_id). Applied to both JE lines when configured.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency key. Already-existing records are skipped.
partner_idstringRequiredExternal customer ID (res.partner.id_from_ext). Lookup order: partner_id (id_from_ext) โ†’ user_id_ext (mobile_user_id fallback) โ†’ auto-create using user_name_ext as name.
create_datestringOptionalDate for the journal entry and history record. Accepted formats: DD/MM/YYYY, YYYY-MM-DD, or YYYY-MM-DD HH:MM:SS.ffffff (time component is stripped; date portion used as accounting date)
descriptionstringOptionalText description stored on the history record and used as journal entry narration
issuednumberOptionalLoyalty points earned. Stored in loyalty.history.issued; added to loyalty.card.points
issued_idr_extnumberOptionalIDR value of earned points. Stored in loyalty.history.issued_idr_ext; used as earn journal entry amount
usednumberOptionalLoyalty points redeemed. Stored in loyalty.history.used; subtracted from loyalty.card.points
used_idr_extnumberOptionalIDR value of redeemed points. Stored in loyalty.history.used_idr_ext; used as redeem journal entry amount
referral_id_extstringOptionalReferral ID. Also accepted as referal_id_ext (without double-l).
user_id_extstringOptionalExternal user / member ID
user_name_extstringOptionalExternal user name. Used to auto-create partner when partner_id is not found.
email_extstringOptionalEmail address from external system
xendit_id_extstringOptionalXendit transaction ID for cross-referencing payment records

Idempotency Key Rules

Record hasEarn history id_from_extRedeem history id_from_ext
Earn only (issued > 0){id_from_ext}โ€”
Redeem only (used > 0)โ€”{id_from_ext}
Both earn and redeem{id_from_ext}{id_from_ext}_redeem

Example JSONL Records

{
  "id_from_ext": "LH-2026-001",
  "partner_id": "CUST-001",
  "create_date": "25/03/2026", // DD/MM/YYYY, YYYY-MM-DD, or YYYY-MM-DD HH:MM:SS.ffffff
  "description": "Top-up bonus",
  "issued": 5000,
  "issued_idr_ext": 50000,
  "used": 0,
  "used_idr_ext": 0,
  "referral_id_ext": "REF-001",
  "user_id_ext": "USR-001",
  "user_name_ext": "Budi Santoso", // used to auto-create partner if not found
  "email_ext": "budi@example.com",
  "xendit_id_ext": "xendit-abc123"
}
Wellness Package
data_type: "wellness_package" or "wellness_packages"  ยท  Creates sale.order โ†’ account.move โ†’ account.payment  ยท  Create-only by id_from_ext
โš ๏ธ
Create-only: Records where id_from_ext already exists as a sale.order are silently skipped โ€” no update is performed.
โš™๏ธ
Required company config โ€” set in Settings โ†’ Accounting โ†’ Wellness Package:
  • wellness_warehouse_id โ€” warehouse used for all wellness SOs
  • wellness_sale_journal_id โ€” sales journal for the invoice (type: sale)
  • wellness_product_id โ€” service product used as the SO/invoice line item (income account follows product category)
  • voucher_product_id โ€” service product for voucher deduction lines (Settings โ†’ Accounting โ†’ Voucher)
Each record produces: one confirmed SO โ†’ one posted invoice โ†’ one reconciled incoming payment. Point earn/redeem and voucher deductions are handled as invoice lines.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency key โ€” skipped if a sale.order with this value already exists
namestringOptionalOrder number โ€” used as SO name and invoice ref
partner_idobjectRequiredCustomer dict โ€” resolved by id_from_ext โ†’ mobile_user_id โ†’ auto-created. mobile_user_id is always synced back to res.partner when it changes.
partner_id.id_from_extstringOptionalExternal customer ID (primary lookup key)
partner_id.namestringOptionalRequired only when auto-creating a new partner
partner_id.mobile_user_idstringOptionalMobile app user ID โ€” stored on res.partner.mobile_user_id; updated even when partner already exists
partner_id.*stringOptionalStandard contact fields: phone, mobile, email, street, street2, city, zip, ref, comment, state_id (match by name), country_id (match by ISO code)
descriptionstringOptionalLine item text โ€” combined with expired_at to form the SO/invoice line name: "{description} DD/MM/YYYY"
expired_atdatetimeOptionalPackage expiry date โ€” formatted DD/MM/YYYY and appended to line name
transaction_datedateOptionalYYYY-MM-DD โ€” used as SO date_order and invoice invoice_date
amountfloatRequiredPayment amount โ€” already deducted by voucher, NOT by point_redeem. SO line price = amount + voucher_amount. Must be > 0.
point_earningfloatOptionalLoyalty points earned โ€” triggers earn JE (same logic as invoice) and loyalty card update
point_earning_idrfloatOptionalIDR value for the earn journal entry amount
point_redeemfloatOptionalLoyalty points redeemed โ€” added as a negative invoice line; deducted from payment amount
point_redeem_idrfloatOptionalIDR value of redeemed points โ€” deducted from payment: actual_payment = amount โˆ’ point_redeem_idr
voucher_amountfloatOptionalVoucher deduction โ€” added as a negative invoice line using voucher_product_id; already pre-deducted from amount
voucher_codestringOptionalVoucher code โ€” used as the label/name of the voucher line item
payment_journal_codestringOptionalid_from_ext of an inbound account.payment.method.line. Required when actual_payment > 0. Set via Accounting โ†’ Journals โ†’ Incoming Payments โ†’ External ID column.

JSON Example

{
  "id_from_ext": "WP-001",
  "name": "WP-2026-001",
  "partner_id": {
    "id_from_ext": "C-001",
    "name": "John Doe",
    "phone": "081234567",
    "email": "john@example.com",
    "mobile_user_id": "MOB-USR-001"
  },
  "description": "Wellness Package Gold",
  "expired_at": "2027-04-01T00:00:00",
  "transaction_date": "2026-04-01",
  "amount": 450000.0,
  "payment_journal_code": "XENDIT-EXT",
  "point_earning": 100,
  "point_earning_idr": 10000.0,
  "point_redeem": 50,
  "point_redeem_idr": 5000.0,
  "voucher_amount": 50000.0,
  "voucher_code": "VOUCH-2026-001"
}

What Gets Created

  1. Sale Order โ€” product from wellness_product_id config; line name = "{description} DD/MM/YYYY"; price_unit = amount + voucher_amount (full price before voucher); qty = 1
  2. Invoice โ€” journal = wellness_sale_journal_id; income account follows product category; optional negative lines for voucher (voucher_product_id) and point redemption (product_loyalty_redemption); ref = name; state = posted
  3. Loyalty โ€” earn JE created via invoice loyalty config; loyalty card points updated (same as invoice earn/redeem flow)
  4. Incoming Payment โ€” actual_payment = amount โˆ’ point_redeem_idr; journal from payment_journal_code; reconciled with invoice

Curl Example

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "wellness_package",
    "s3_location": "bucket/prefix/wellness_2026-04-01.jsonl"
  }'
Wellness Redemption
data_type: "wellness_redemption" or "wellness_redemptions"  ยท  Creates a posted account.move (Journal Entry)  ยท  Create-only by id_from_ext
โ„น๏ธ
Dual behaviour controlled by redeem field (default: true):
  • redeem: true (or omitted) โ€” Create: posts a new Journal Entry. Skipped silently if an account.move (entry) with this id_from_ext already exists.
  • redeem: false โ€” Cancel: looks up the original JE by id_from_ext and posts a reversal JE (debit/credit swapped). Skipped if the original is not found or already reversed. transaction_date is used as the reversal JE date.
โš™๏ธ
Required company config โ€” set in Settings โ†’ Accounting โ†’ Wellness Package:
  • wellness_sale_journal_id โ€” Journal for the JE (type: sale)
  • wellness_accrued_account_id โ€” Account debited (moves balance out of accrual)
  • wellness_revenue_account_id โ€” Revenue account credited (recognizes actual revenue)
  • wellness_analytic_account_id โ€” Analytic account applied to the credit line (optional)
Each record produces one posted Journal Entry: Dr. accrued account / Cr. revenue account + analytic.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency/lookup key. On create: skipped if JE already exists. On cancel: used to find the original JE to reverse.
redeembooleanOptionalDefault true. true = create a new redemption JE. false = cancel: post a reversal JE for the existing JE identified by id_from_ext.
partner_idobjectOptionalCustomer dict โ€” matched by id_from_ext, auto-created if not found (requires name). Not required when redeem=false.
partner_id.id_from_extstringOptionalExternal customer ID
partner_id.namestringOptionalRequired only when auto-creating a new partner
product_idstringredeem=trueid_from_ext of a product.template in Odoo โ€” must exist. Not required when redeem=false.
product_barcodestringOptionalProduct barcode from Digitail โ€” used in JE ref and line description
product_namestringOptionalProduct name from Digitail โ€” used in JE ref and line description
transaction_datedateOptionalYYYY-MM-DD โ€” the redemption date; used as JE date. Sourced from redeemed_at on Digitail side.
appointment_idstringOptionalAppointment ID from Digitail โ€” used in ref/line description: Ticket Redeem on {appointment_id} for {barcode} - {product_name}
amountfloatRequiredVisit type amount based on the linked product. Must be > 0. Used for both debit and credit lines.
descriptionstringOptionalUsed as JE narration (internal note)

JSON Example โ€” Create (redeem=true)

{
  "id_from_ext": "WR-2026-001",
  "redeem": true, // default โ€” can be omitted
  "partner_id": {
    "id_from_ext": "C-001",
    "name": "John Doe"
  },
  "product_id": "PROD-WP-001",
  "product_barcode": "WP-BARCODE-001",
  "product_name": "Wellness Package Gold",
  "transaction_date": "2026-04-21",
  "appointment_id": "APT-2026-001",
  "amount": 150000.0,
  "description": "Optional internal note"
}

JSON Example โ€” Cancel (redeem=false)

{
  "id_from_ext": "WR-2026-001", // must match an existing JE
  "redeem": false,
  "transaction_date": "2026-05-01" // date of the reversal JE; defaults to today if omitted
}

What Gets Created

redeem=true (create):

  1. Journal Entry (move_type=entry) โ€” posted immediately; journal = wellness_sale_journal_id; date = transaction_date; ref = Ticket Redeem on {appointment_id} for {barcode} - {product_name}
  2. Debit line โ€” account = wellness_accrued_account_id; amount = amount
  3. Credit line โ€” account = wellness_revenue_account_id; amount = amount; analytic_distribution = wellness_analytic_account_id (100%) if configured

redeem=false (cancel):

  1. Looks up original JE by id_from_ext in account.move (entry)
  2. Creates a reversal Journal Entry using Odoo's _reverse_moves() โ€” debit/credit swapped; date = transaction_date; posted immediately
  3. ref on the reversal is set to "Reversed from {JE name} ({original ref})" โ€” e.g. Reversed from WP/2026/00050 (Ticket Redeem on APT-001 for WP-BARCODE-001 - Wellness Package Gold)
  4. partner_id is explicitly copied from the original JE (written after reversal creation, before posting)
  5. reversed_entry_id on the reversal points back to the original JE (set automatically by Odoo)

Curl Example

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "wellness_redemption",
    "s3_location": "bucket/prefix/wellness_redemption_2026-04-21.jsonl"
  }'
Membership Purchase
data_type: "membership_purchase" or "membership_purchases"  ยท  Creates Sale Order โ†’ Invoice โ†’ Payment  ยท  Create-only by id_from_ext
โš ๏ธ
Create-only: Records where id_from_ext already exists as a sale.order are silently skipped.
โš™๏ธ
Required company config โ€” set in Settings โ†’ Accounting โ†’ Membership Purchase:
  • membership_sale_journal_id โ€” Sales journal (type: sale) used for the invoice
  • membership_product_id โ€” Service product used as the SO/invoice line item
  • membership_payment_method_id โ€” Inbound payment method line for registering payment
  • membership_warehouse_id โ€” Warehouse set on the SO and invoice; falls back to company default if not configured (optional)
  • membership_analytic_account_id โ€” Analytic account applied to invoice line + receivable line (optional)
Each record produces: Sale Order โ†’ Confirmed โ†’ Invoice (out_invoice) โ†’ Posted โ†’ Payment registered.

Parameters

FieldTypeRequiredDescription
id_from_extstringRequiredIdempotency key โ€” skipped if a sale.order with this value already exists
namestringOptionalOrder/transaction number โ€” stored as SO name and invoice ref
partner_idobjectRequiredCustomer dict โ€” resolved by id_from_ext โ†’ mobile_user_id โ†’ auto-created
partner_id.id_from_extstringOptionalExternal customer ID (primary lookup key)
partner_id.mobile_user_idstringOptionalMobile app user ID โ€” fallback lookup; updated on res.partner if partner already exists
partner_id.namestringOptionalRequired only when auto-creating a new partner
transaction_datedateOptionalYYYY-MM-DD โ€” used as SO date_order and invoice_date
amountfloatRequiredTransaction amount. Must be > 0. Used as SO line price_unit.
membership_start_datedateRequiredYYYY-MM-DD โ€” deferred revenue start date on invoice line; record skipped if missing
membership_end_datedateRequiredYYYY-MM-DD โ€” deferred revenue end date on invoice line; record skipped if missing
descriptionstringOptionalUsed as SO/invoice line name

JSON Example

{
  "id_from_ext": "MBR-2026-001",
  "name": "MBR-2026-001",
  "partner_id": {
    "id_from_ext": "C-001",
    "name": "John Doe",
    "mobile_user_id": "MOB-USR-001"
  },
  "transaction_date": "2026-04-10",
  "amount": 200000.0,
  "membership_start_date": "2026-04-10",
  "membership_end_date": "2027-04-09",
  "description": "New Membership Purchase"
}

What Gets Created

  1. Sale Order โ€” date = transaction_date; SO name = name; product = membership_product_id; line price = amount; line name = description
  2. Confirm Sale Order โ†’ creates stock picking (not validated)
  3. Invoice (out_invoice) โ€” from SO via _create_invoices(); journal = membership_sale_journal_id (sale type); deferred start/end = membership_start_date / membership_end_date; analytic on line + receivable
  4. Post Invoice โ†’ action_post()
  5. Payment โ€” inbound payment registered via membership_payment_method_id; amount = amount

Curl Example

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "membership_purchase",
    "s3_location": "bucket/prefix/membership_2026-04-01.jsonl"
  }'
Full Flow Example
End-to-end test using examples/claude_should_not_touch_this/ โ€” run steps in order. Each step uses local: true so no S3 required.

โš™๏ธ Prerequisites โ€” Configure Before Running

WhatWhere in OdooValue used in examples
ETL API Key Settings โ†’ ETL API Keys (etl.api.key) Any active key โ€” replace YOUR_KEY_HERE in curl commands
Warehouse id_from_ext Inventory โ†’ Configuration โ†’ Warehouses โ†’ External ID field "3296" (set this on the target warehouse)
Payment Method Line id_from_ext Accounting โ†’ Configuration โ†’ Journals โ†’ Incoming Payments tab โ†’ External ID column "PMETHOD-EXT-0001" (set this on the payment method line that should receive the payment)
Loyalty Journal Config โ€” Invoice Settings โ†’ Accounting โ†’ Loyalty Configuration (per company) Journal + Payable account + Revenue Deduction account โ€” required for point earn JE on invoice. Analytic is taken from warehouse_id.analytic_account_id (set on the warehouse record, not here).
Loyalty Journal Config โ€” Non-Invoice Settings โ†’ Accounting โ†’ Loyalty Configuration (per company) Journal + Payable account + Revenue Deduction account + Analytic Account (Non-Invoice Loyalty) โ€” required for loyalty history JEs. The analytic account here is applied to both JE lines.
Customer Deposit Account Settings โ†’ Accounting โ†’ Customer Deposit โ†’ Deposit Account Account used as counterpart on deposit payments (replaces default receivable). Must have reconcile=True (auto-set).
Customer Deposit Transfer Journal Settings โ†’ Accounting โ†’ Customer Deposit โ†’ Deposit Transfer Journal Journal used for the transfer entry when applying a deposit to an invoice (typically a General journal).
Inbound Payment Method Line id_from_ext Accounting โ†’ Configuration โ†’ Journals โ†’ Incoming Payments tab โ†’ External ID column "PMETHOD-EXT-0001" โ€” required for deposit payment_journal_code. Must be an Inbound method line.
product_global_discount Auto-created on module install (pet_sync_etl.product_global_discount) Used for global_discount_amount lines on sales & invoices
product_loyalty_redemption Auto-created on module install (pet_sync_etl.product_loyalty_redemption) Used for point_redeem_idr lines on invoices
loyalty_program_modernvet Auto-created on module install (pet_sync_etl.loyalty_program_modernvet) Required for all loyalty card & history sync
Wellness Package Config Settings โ†’ Accounting โ†’ Wellness Package wellness_warehouse_id, wellness_sale_journal_id, wellness_product_id, wellness_accrued_account_id, wellness_revenue_account_id, wellness_analytic_account_id (optional). Voucher: voucher_product_id (Settings โ†’ Accounting โ†’ Voucher)
Membership Purchase Config Settings โ†’ Accounting โ†’ Membership Purchase membership_sale_journal_id (sale journal), membership_product_id (service product), membership_payment_method_id (inbound payment method), membership_warehouse_id (optional), membership_analytic_account_id (optional)
๐Ÿ“ฆ 1. Product (consu)
โ†’
๐Ÿ”ง 2. Product (service)
โ†’
๐Ÿ›’ 3. Sales
โ†’
๐Ÿงพ 4. Invoice + Payment
โ†’
๐ŸŽ 5. Loyalty History
โ†’
โ†ฉ๏ธ 6. Refund + Outgoing Payment
๐Ÿ’ฐ 7a. Deposit (standalone)
โ†’
๐Ÿงพ 7b. Invoice referencing Deposit
โ†’
โœ… Transfer JE reconciles deposit โ†’ invoice
โ„น๏ธ
How invoice links to sales: Invoice lines use the same id_from_ext as the sale order lines (SOL-001-01, SOL-001-02). The ETL matches them and sets sale_line_ids automatically.
Payment deduction: payment.amount is the pre-deduction total. The ETL subtracts the global discount line value internally before registering the payment.
Step 1 Product โ€” Consumable (CONSU-001)
๐Ÿ“‹
Creates Royal Canin Maxi Adult 10kg as type=consu with a private UOM category (Unit / Box(4)). Category Dog Food โ†’ Pet Food is created if missing.

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "product",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\product",
    "local": true
  }'

Payload

{
  "id_from_ext": "CONSU-001",
  "name": "Royal Canin Maxi Adult 10kg",
  "type": "consu",
  "list_price": 450000.0,
  "default_code": "RC-MAXI-10K",
  "barcode": "7612938888001",
  "product_size": "10kg",
  "brand_id": "Royal Canin",
  "categ_id": {
    "id_from_ext": "CAT-DOG-FOOD",
    "name": "Dog Food",
    "parent_id": {
      "id_from_ext": "CAT-PET-FOOD",
      "name": "Pet Food",
      "property_cost_method": "average",
      "property_valuation": "real_time"
    },
    "property_cost_method": "average",
    "property_valuation": "real_time"
  },
  "is_storable": false,
  "invoice_policy": "delivery",
  "purchase_method": "receive",
  "x_uom_category_id": [
    {"name": "Unit", "uom_type": "sub", "ratio": 1.0},
    {"name": "Box(4)", "uom_type": "main", "ratio": 4.0}
  ],
  "description": "Internal notes text",
  "deleted_at": "", // empty = product stays active
  "disabled_at": "",
  "warehouse_id": "3296"
}
Step 2 Product โ€” Service (SVC-001)
๐Ÿ“‹
Creates Basic Health Checkup as type=service. Has additional_products referencing CONSU-001 โ€” Step 1 must run first. deleted_at is empty so the product stays active.

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "product",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\service",
    "local": true
  }'

Payload

{
  "id_from_ext": "SVC-001",
  "name": "Basic Health Checkup",
  "type": "service",
  "list_price": 150000.0,
  "default_code": "SVC-REF",
  "additional_products": [
    {
      "id_from_ext": "CONSU-001", // must exist โ€” run Step 1 first
      "quantity": 1.0,
      "uom_ext_id": "Unit"
    }
  ],
  "categ_id": {
    "id_from_ext": "CAT-VET-EXAM",
    "name": "Veterinary Examination",
    "parent_id": {
      "id_from_ext": "CAT-VET-SVC",
      "name": "Veterinary Services",
      "property_cost_method": "average",
      "property_valuation": "real_time"
    },
    "property_cost_method": "average",
    "property_valuation": "real_time"
  },
  "invoice_policy": "order",
  "description": "Internal notes text",
  "deleted_at": "", // empty = product stays active
  "disabled_at": "",
  "warehouse_id": "3332",
  "status": "enabled",
  "barcode": ""
}
Step 3 Sales Order (SO-2026-001)
๐Ÿ“‹
Creates customer Budi Santoso (CUST-001) inline if not found, then creates the sale order with 2 lines.
discount_fixed only โ€” do not set both discount_fixed and discount to non-zero values at the same time (constraint error).
Amounts: 890,000 + 145,000 โˆ’ 35,000 (global discount) = 1,000,000

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "sales",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\sales",
    "local": true
  }'

Payload

{
  "id_from_ext": "SO-2026-001",
  "name": "SO-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso",
    "street": "Jl. Sudirman 1",
    "street2": "Lt. 5",
    "city": "Jakarta",
    "zip": "12190",
    "phone": "021-5551234",
    "mobile": "081234567890",
    "email": "budi@example.com",
    "ref": "MBR-001",
    "comment": "VIP customer"
  },
  "pet_name": "Max",
  "date_order": "2026-03-25 09:00:00",
  "warehouse_id": "3296",
  "order_line": [
    {
      "id_from_ext": "SOL-001-01", // invoice step uses this same id_from_ext to link
      "product_id": "CONSU-001",
      "name": "Royal Canin Maxi Adult 10kg",
      "product_uom": "Unit",
      "product_uom_qty": 2.0,
      "price_unit": 450000.0,
      "discount_fixed": 5000, // fixed discount โ€” discount must be 0 when this is set
      "discount": 0,
      "price_subtotal": 890000.0 // verification only โ€” 2 ร— (450000 โˆ’ 5000)
    },
    {
      "id_from_ext": "SOL-001-02", // invoice step uses this same id_from_ext to link
      "product_id": "SVC-001",
      "name": "Basic Health Checkup",
      "product_uom": "Unit",
      "product_uom_qty": 1.0,
      "price_unit": 150000.0,
      "discount_fixed": 5000,
      "discount": 0,
      "price_subtotal": 145000.0 // verification only โ€” 1 ร— (150000 โˆ’ 5000)
    }
  ],
  "note": "Sales from WH-00001",
  "global_discount_amount": 35000, // adds Global Discount line: โˆ’35,000
  "amount_untaxed": 1000000, // verification only
  "amount_tax": 0,
  "amount_total": 1000000 // 890000 + 145000 โˆ’ 35000
}
Step 4 Invoice + Payment (INV-2026-001)
๐Ÿ“‹
Creates invoice linked to SO-2026-001 via matching id_from_ext on each line (SOL-001-01, SOL-001-02).
Posts the invoice, then registers payment. Payment deduction: global discount (โˆ’35,000) and loyalty redemption (โˆ’50,000) lines are both subtracted from payment.amount before the wizard runs:
actual_payment = 1,035,000 โˆ’ 85,000 (35,000 + 50,000) = 950,000
Amounts: 890,000 + 145,000 โˆ’ 35,000 (global discount) โˆ’ 50,000 (point redeem) = 950,000

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "invoice",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\invoice",
    "local": true
  }'

Payload

{
  "id_from_ext": "INV-2026-001",
  "name": "TRX-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001", // looked up first; sub-fields used only if not found
    "name": "Budi Santoso",
    "street": "Jl. Sudirman 1",
    "street2": "Lt. 5",
    "city": "Jakarta",
    "zip": "12190",
    "phone": "021-5551234",
    "mobile": "081234567890",
    "email": "budi@example.com",
    "ref": "MBR-001",
    "comment": "VIP customer"
  },
  "pet_name": "Max",
  "invoice_date": "2026-03-25",
  "invoice_date_due": "2026-03-25",
  "ref": "SO-2026-001",
  "warehouse_id": "3296",
  "invoice_line_ids": [
    {
      "id_from_ext": "SOL-001-01", // matches sale.order.line โ†’ sets sale_line_ids
      "product_id": "CONSU-001",
      "product_uom": "Unit",
      "name": "Royal Canin Maxi Adult 10kg",
      "quantity": 2.0,
      "price_unit": 450000.0,
      "discount_fixed": 5000,
      "discount": 0,
      "price_subtotal": 890000.0 // verification only
    },
    {
      "id_from_ext": "SOL-001-02", // matches sale.order.line โ†’ sets sale_line_ids
      "product_id": "SVC-001",
      "product_uom": "Unit",
      "name": "Basic Health Checkup",
      "quantity": 1.0,
      "price_unit": 150000.0,
      "discount_fixed": 5000,
      "discount": 0,
      "price_subtotal": 145000.0 // verification only
    }
  ],
  "narration": "Invoice of SO-2026-001 from WH-00001",
  "global_discount_amount": 35000, // adds Global Discount line: โˆ’35,000
  "amount_untaxed_in_currency_signed": 985000, // verification only
  "amount_tax_in_currency_signed": 0,
  "amount_total": 985000, // verification only
  "payment_list": [
    {
      "date": "2026-03-25",
      "amount": 1035000.0, // pre-deduction: ETL subtracts 85000 (35000+50000) โ†’ actual 950000
      "memo": "Bank Transfer SO-2026-001",
      "payment_journal_code": "PMETHOD-EXT-0001", // id_from_ext on account.payment.method.line
      "payment_type_name_ext": "Bank Transfer",
      "payment_type_card_brand_ext": ""
    }
  ],
  "amount_due_in_currency_signed": 985000, // verification only
  "point_earning": 2000,
  "point_redeem": 5000,
  "point_earning_idr": 20000,
  "point_redeem_idr": 50000 // adds Loyalty Redemption line: โˆ’50,000 (also counted as deduction)
}
Step 5 Loyalty History โ€” Non-Invoice (LH-2026-001)
๐Ÿ“‹
Creates a loyalty history entry not linked to any invoice โ€” a manual top-up bonus for CUST-001. Creates the loyalty card if it doesn't exist yet.
issued: 5000 โ†’ card balance +5000 pts. used: 0 โ†’ no redemption entry.
Note: loyalty history is create-only โ€” re-running with the same id_from_ext will skip.

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "loyalty_history",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\history_loyalty",
    "local": true
  }'

Payload

{
  "id_from_ext": "LH-2026-001",
  "partner_id": "CUST-001", // plain string = id_from_ext of res.partner
  "create_date": "25/03/2026", // DD/MM/YYYY, YYYY-MM-DD, or YYYY-MM-DD HH:MM:SS.ffffff
  "description": "Top-up bonus",
  "issued": 5000,
  "issued_idr_ext": 50000,
  "used": 0,
  "used_idr_ext": 0,
  "referral_id_ext": "Budi Santoso",
  "user_id_ext": "Budi Santoso",
  "user_name_ext": "Budi Santoso", // fallback name if partner not found by id_from_ext
  "email_ext": "budi@example.com",
  "xendit_id_ext": "xendit-abc123"
}
Step 6 Refund / Credit Note (REFUND-2026-001)
๐Ÿ“‹
Creates a credit note reversing INV-2026-001. Requires the original invoice to be fully paid (in_payment or paid).
The ETL adds a Loyalty Redemption line (โˆ’50,000) automatically from point_redeem_idr.
Payment deduction: actual_payment = 1,035,000 โˆ’ 50,000 (redeem) = 985,000.
Loyalty earn deduction JE (โˆ’2,000 pts) and redeem restoration JE (+5,000 pts) are created after posting.
Note: the credit note is a standalone document โ€” it is not reconciled against the original invoice (which is already fully paid).
โš ๏ธ
Outgoing payment method setup required: payment_journal_code must match id_from_ext on an Outgoing account.payment.method.line. Go to Accounting โ†’ Configuration โ†’ Journals โ†’ Outgoing Payments tab โ†’ External ID column and fill in the value.

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "refund",
    "s3_location": "C:\\Users\\AXIOO\\Desktop\\workspace\\modernvet\\custom\\modernvet-odoo\\pet_sync_etl\\examples\\claude_should_not_touch_this\\refund",
    "local": true
  }'

Payload

{
  "id_from_ext": "REFUND-2026-001",
  "name": "RINV/2026/RF/001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso"
  },
  "invoice_date": "2026-03-25",
  "warehouse_id": "3296",
  "reversed_entry_id": "INV-2026-001", // id_from_ext of original paid invoice โ€” required
  "invoice_line_ids": [
    {
      "id_from_ext": "RLINE-001",
      "product_id": "CONSU-001",
      "quantity": 2.0,
      "price_unit": 450000.0,
      "discount_fixed": 5000,
      "price_subtotal": 890000.0 // verification only
    }
  ],
  "global_discount_amount": 35000, // adds Global Discount line: โˆ’35,000 (also deducted from payment)
  "payment_list": [
    {
      "payment_journal_code": "CASH-EXT", // id_from_ext of outgoing account.payment.method.line
      "amount": 1035000.0, // pre-deduction: ETL subtracts 50000 (redeem) โ†’ actual 985000
      "date": "2026-03-25",
      "memo": "Refund Bank Transfer SO-2026-001"
    }
  ],
  "point_earning": 2000, // points to deduct โ€” JE: Debit Payable, Credit Revenue Deduction
  "point_earning_idr": 20000,
  "point_redeem": 5000, // points to restore โ€” JE: Debit Revenue Deduction, Credit Payable; also adds Loyalty Redemption line
  "point_redeem_idr": 50000 // adds Loyalty Redemption line: โˆ’50,000 (also counted as deduction)
}
๐Ÿงฎ
In this example: actual payment = 1,035,000 โˆ’ 50,000 (redeem) โˆ’ 35,000 (discount) = 950,000. Credit note total = 890,000 โˆ’ 35,000 โˆ’ 50,000 = 805,000. The refund is a standalone credit note โ€” not reconciled against the original invoice (already paid).
Step 7 Deposit โ†’ Invoice with Deposit Applied
๐Ÿ’ฐ
Two separate syncs. First, create the deposit payment (data_type: "deposit"). Then, create the invoice (data_type: "invoice") with a payment_list entry that references the deposit via payment_id_from_ext.

The ETL creates a transfer journal entry: Debit Customer Deposit Account โ†’ Credit Receivable, then reconciles the deposit credit line with the transfer debit line. The invoice residual drops by the applied amount.

Amounts: Deposit = 500,000. Invoice = 300,000. Applied = 300,000 โ†’ residual = 0. Remaining deposit balance = 200,000.
Step 7a โ€” Deposit

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "deposit",
    "s3_location": "C:\\path\\to\\deposit.json",
    "local": true
  }'

Payload

{
  "id_from_ext": "DEP-2026-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso"
  },
  "warehouse_id": "3296",
  "date": "2026-04-01",
  "amount": 500000.0,
  "memo": "Advance deposit April 2026",
  "payment_journal_code": "PMETHOD-EXT-0001", // id_from_ext of INBOUND account.payment.method.line
  "payment_type_name_ext": "BCA Transfer"
}
Step 7b โ€” Invoice referencing the deposit

Trigger

curl -s -X POST http://localhost:8069/api/v1/etl/notifications \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY_HERE" \
  -d '{
    "data_type": "invoice",
    "s3_location": "C:\\path\\to\\invoice_with_deposit.json",
    "local": true
  }'

Payload

{
  "id_from_ext": "INV-2026-DEP-001",
  "name": "TRX-2026-DEP-001",
  "partner_id": {
    "id_from_ext": "CUST-001",
    "name": "Budi Santoso"
  },
  "invoice_date": "2026-04-05",
  "invoice_date_due": "2026-04-05",
  "warehouse_id": "3296",
  "invoice_line_ids": [
    {
      "id_from_ext": "IDEP-L01",
      "product_id": "SVC-001",
      "name": "Basic Health Checkup",
      "quantity": 2.0,
      "price_unit": 150000.0
    }
  ],
  "payment_list": [
    {
      "payment_id_from_ext": "DEP-2026-001", // references deposit โ€” triggers transfer JE, NOT a direct payment
      "amount": 300000.0, // partial application; capped to min(deposit balance, invoice residual)
      "date": "2026-04-05",
      "memo": "Apply deposit DEP-2026-001"
    }
  ]
}
๐Ÿงฎ
Result: Invoice total = 300,000. Deposit applied = 300,000 โ†’ amount_residual = 0.
A transfer JE is created with ref "Deposit DEP-2026-001 applied to INV-2026-DEP-001".
Deposit remaining balance = 500,000 โˆ’ 300,000 = 200,000 (can be applied to future invoices).
Idempotent: re-syncing the invoice skips the deposit application if the deposit line is already reconciled.
API Reference
All endpoints require authentication via X-API-Key or Authorization: Bearer header
POST

/api/v1/etl/notifications

Enqueue a sync job. Returns immediately with a log_id.

Body FieldTypeRequiredDescription
data_typeenumRequired"product", "products", "sales", "sale", "invoice", "invoices", "refund", "refunds", "deposit", "deposits", "deposit_refund", "deposit_refunds", "loyalty_history", "wellness_package", "wellness_packages", "wellness_redemption", "wellness_redemptions", "membership_purchase", "membership_purchases"
s3_locationstringRequiredS3 path (bucket/prefix or bucket/path/to/file.json) or local path when local=true
localbooleanOptionalDefault false. When true: reads from local filesystem, writes result CSV next to input file.
Response 200:
{"status": "queued", "log_id": 42}
GET

/api/v1/etl/logs/{log_id}

Poll sync job status. Returns counters and state.

{
  "log_id": 42,
  "status": "done",
  "data_type": "invoice",
  "created": 12,
  "updated": 3,
  "skipped": 0,
  "errors": 1,
  "duration": 4.23,
  "started_at": "2026-03-25T09:01:00",
  "finished_at": "2026-03-25T09:01:04",
  "error": "[{\"id_from_ext\":\"INV-X\",\"error\":\"partner not found\"}]"
}
GET

/api/v1/etl/s3/list?s3_location={bucket/prefix}

List objects at an S3 prefix. Useful for debugging S3 path resolution before triggering a sync.