Push records from AWS S3 (or local files) into Odoo 18. Processing is asynchronous โ trigger once, poll for result.
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).
POST a JSON body to /api/v1/etl/notifications:
data_type โ what to syncs3_location โ S3 bucket/prefix or local pathlocal: true โ read from filesystemReturns {"status":"queued","log_id":42} immediately.
GET /api/v1/etl/logs/{log_id} to check progress. States: queued โ running โ done / failed.
error field for exception detailss3_location as a direct object keyMultiple files are concatenated before parsing.
Files must be JSON array or JSONL (one object per line). Every record must have id_from_ext as the upsert key.
.json or .jsonlAll syncs are safe to re-run. Records are matched by id_from_ext:
id_from_ext is skippedid_from_ext is skippedConfigure in Odoo Settings โ Technical โ System Parameters:
pet_sync_etl.aws_region โ default: ap-southeast-3pet_sync_etl.aws_access_key_idpet_sync_etl.aws_secret_access_keyLeave key/secret empty to use IAM role credentials.
Required for loyalty journal entries. Configure per company in Settings โ Accounting โ Loyalty Configuration:
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.
id_from_extSales, 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.
id_from_extInvoice 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:
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.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.data_type: "deposit") โ looks up Incoming Payments method line (same setup as Invoice).data_type: "deposit_refund") โ looks up Outgoing Payments method line (same setup as Refund).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/invoicespet_sync_etl.product_loyalty_redemption โ product used for the loyalty redemption line on invoicespet_sync_etl.loyalty_program_modernvet โ loyalty program used for card & history syncIf any of these are missing, the related feature is silently skipped (no error, just a warning in the server log).
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"
}'
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
}'
curl -s http://localhost:8069/api/v1/etl/logs/42 \ -H "X-API-Key: YOUR_KEY_HERE"
data_type: "product" ยท Target model: product.template ยท Upsert by id_from_ext
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.| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key for upsert |
| name | string | On Create | Product name |
| type | enum | Optional | Maps to Odoo type. Values: "consu". Default: "consu" |
| is_storable | boolean | Optional | Set true to enable inventory tracking |
| list_price | number | Optional | Sales price |
| standard_price | number | Optional | Cost price |
| default_code | string | Optional | Internal reference |
| barcode | string | Optional | Stored in barcode_from_ext on the product template |
| product_size | string | Optional | Size/weight label (custom field from pet_stock) |
| brand_id | string | Optional | Brand name โ searched or created in product.brand |
| description | string | Optional | Internal notes |
| description_sale | string | Optional | Customer-facing description |
| description_purchase | string | Optional | Vendor-facing description |
| active | boolean | Optional | Default true. Set false to archive. |
| deleted_at | string | Optional | Any non-empty value archives the product (active=false) |
| disabled_at | string | Optional | Any non-empty value sets sale_ok=false |
| weight | number | Optional | Product weight (kg) |
| volume | number | Optional | Product volume (mยณ) |
| invoice_policy | enum | Optional | "order" (invoice immediately) or "delivery" (after delivery). Default: "delivery" |
| purchase_method | enum | Optional | "purchase" or "receive" |
| warehouse_id | string | Optional | id_from_ext of stock.warehouse |
| categ_id | object | Optional | Product category โ see Category Schema below |
| x_uom_category_id | array | Optional | UOM units list. Replaces all existing units in the product's private UOM category โ see UOM Schema |
| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key |
| name | string | Required | Category name |
| parent_id | string | object | Optional | String = id_from_ext of parent, or nested Category object (recursive) |
| property_cost_method | enum | Optional | "standard", "average", "fifo". Default: "standard" |
| property_valuation | enum | Optional | "manual_periodic" or "real_time". Default: "manual_periodic" |
| deleted_at | string | Optional | Stored in deleted_at_ext on the category for informational purposes |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Unit name e.g. "Unit", "Box(12)" |
| uom_type | enum | Required | "sub" = reference unit (1:1 ratio), "main" = larger packaging unit. Only one "sub" per category. |
| ratio | number | Optional | Conversion factor relative to the reference unit |
{
"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"
}
data_type: "product" ยท Target model: product.template ยท Upsert by id_from_ext
invoice_policy defaults to "order" (invoiceable immediately after sale confirmation).| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key for upsert |
| name | string | On Create | Product name |
| type | enum | Optional | Must be "service" |
| list_price | number | Optional | Sales price |
| standard_price | number | Optional | Cost price |
| default_code | string | Optional | Internal reference |
| description | string | Optional | Internal notes |
| description_sale | string | Optional | Customer-facing description |
| active | boolean | Optional | Default true |
| deleted_at | string | Optional | Any non-empty value archives the product |
| status | string | Optional | "enabled" โ sale_ok=true; "disabled" โ sale_ok=false |
| invoice_policy | enum | Optional | "order" (default) or "delivery" |
| categ_id | object | Optional | Same structure as Consumable category |
{
"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
}
data_type: "sales" or "sale" ยท Target model: sale.order ยท Upsert by id_from_ext
draft orders can be updated. Confirmed (sale) or cancelled orders are skipped on re-sync. Pass "state": "sale" to auto-confirm after upsert.| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key for upsert |
| partner_id | object | Required | Object with id_from_ext of res.partner |
| partner_id.id_from_ext | string | Required | External ID of the customer partner |
| date_order | string | Optional | Format: YYYY-MM-DD HH:MM:SS |
| state | enum | Optional | "draft", "sale", or "done". Values "sale"/"done" trigger auto-confirm. |
| client_order_ref | string | Optional | Customer reference number |
| ref | string | Optional | Stored as client_order_ref |
| note | string | Optional | Internal note / terms |
| pet_name | string | Optional | Patient/pet name (custom field) |
| warehouse_id | string | Optional | id_from_ext of stock.warehouse |
| global_discount_amount | number | Optional | Adds a negative-price Global Discount line to the order |
| loyalty_redemption_amount | number | Optional | Adds a negative-price Loyalty Redemption line to the order |
| name | string | Optional | SO number override e.g. SO-2026-001. Set on create only; bypasses sequence constraint. |
| order_line | array | Optional | Lines are fully replaced when present. See Order Line below. |
| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Optional | Used to link invoice lines to SO lines |
| product_id | string | Optional | id_from_ext of product.template |
| product_uom_qty | number | Optional | Quantity. Default: 1.0 |
| price_unit | number | Optional | Unit price |
| discount | number | Optional | Discount percentage (0โ100). Ignored when discount_fixed is set. |
| discount_fixed | number | Optional | Fixed-amount discount per line (IDR). When set, overrides discount %. |
| name | string | Optional | Line description |
| product_uom | string | Optional | UOM name within the product's private UOM category |
{
"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
}
data_type: "invoice" or "invoices" ยท Target model: account.move ยท Upsert by id_from_ext
voucher_amount is set โ negative line using voucher_product_id; label = voucher_codepoint_redeem_idr > 0global_discount < 0payment_list:
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.payment_id_from_ext โ applies an existing deposit credit via a transfer journal entry (no new payment created)| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key for upsert |
| partner_id | object | Required | Customer โ matched by id_from_ext; auto-created if not found (requires name) |
| partner_id.id_from_ext | string | Required | External ID of customer |
| partner_id.name | string | Optional | Used to auto-create partner if not found |
| partner_id.email | string | Optional | Partner email (on auto-create) |
| partner_id.phone | string | Optional | Partner phone (on auto-create) |
| name | string | Optional | Invoice number e.g. TRX-2026-00001. Set on create only. |
| move_type | enum | Optional | "out_invoice" (default) or "in_invoice". For credit notes use data_type: "refund" instead. |
| invoice_date | date | Optional | Format: YYYY-MM-DD |
| invoice_date_due | date | Optional | Due date. Format: YYYY-MM-DD |
| ref | string | Optional | Source document reference |
| narration | string | Optional | Internal notes / terms |
| pet_name | string | Optional | Patient/pet name (custom field) |
| warehouse_id | string | Optional | id_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_amount | number | Optional | Positive value e.g. 25000. Adds a Global Discount line (stored as negative price). Also accepted as global_discount (negative). Amount deducted from payment. |
| membership_discount | number | Optional |
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_discountCr 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_discountCr 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_amount | number | Optional | Voucher 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_code | string | Optional | Voucher code โ used as the line description on the voucher deduction line. |
| point_earning | number | Optional | Loyalty points earned. Stored on invoice; triggers loyalty earn history + misc JE only when payment_list is present. |
| point_earning_idr | number | Optional | IDR 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_redeem | number | Optional | Loyalty points redeemed. Creates loyalty redeem history. Adds Loyalty Redemption line to invoice. |
| point_redeem_idr | number | Optional | IDR value of redeemed points. Used as redemption line amount (price_unit = โpoint_redeem_idr). Stored in used_idr_ext. |
| invoice_line_ids | array | Optional | Invoice lines โ upserted by id_from_ext. Lines absent from incoming data are deleted. |
| payment_list | array | Optional | Payments to register. Only applied when state=posted and invoice has no existing payments. |
| amount_untaxed_in_currency_signed | number | Optional | Validation only โ mismatch logged in manual_check_missmatch |
| amount_total | number | Optional | Validation only |
point_earning / point_earning_idr are set and payment_list is present, a misc journal entry is created after payment registration:
point_earning_idr)warehouse_id.analytic_account_id (set on the warehouse record under Inventory โ Configuration โ Warehouses โ Analytic Account)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.
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.| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Optional | Match key for upsert; also used to link to sale.order.line |
| product_id | string | Optional | id_from_ext of product.template |
| name | string | Optional | Line description |
| quantity | number | Optional | Quantity. Default: 1.0 |
| price_unit | number | Optional | Unit price |
| discount | number | Optional | Discount percentage (0โ100). Ignored when discount_fixed is set. |
| discount_fixed | number | Optional | Fixed-amount discount per line (IDR). When set, overrides discount %. |
| price_subtotal | number | Optional | Validation only โ mismatch logged |
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.| Field | Type | Required | Description |
|---|---|---|---|
| payment_journal_code | string | Conditional | id_from_ext of an inbound account.payment.method.line. Creates a new account.payment. Mutually exclusive with payment_id_from_ext. |
| payment_id_from_ext | string | Conditional | id_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. |
| amount | number | Required | Full 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). |
| date | date | Optional | Payment date. Also accepted as payment_date. Format: YYYY-MM-DD |
| memo | string | Optional | Payment note / communication |
| payment_type_name_ext | string | Optional | Stored in account.payment.payment_type_name_ext for external tracking (bank/cash payments only) |
| payment_type_card_brand_ext | string | Optional | Card brand stored in account.payment.payment_type_card_brand_ext (bank/cash payments only) |
{
"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
}
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.data_type: "refund" or "refunds" ยท Target model: account.move (out_refund) ยท Create-only by id_from_ext
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.move_type is not required โ data_type: "refund" always creates out_refund. When state=posted, the ETL will:
payment_list (method line matched by id_from_ext + payment_type=outbound)point_earning) โ JE: Debit Payable, Credit Revenue Deductionpoint_redeem) โ JE: Debit Revenue Deduction, Credit Payable| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Match key for upsert |
| partner_id | object | Required | Customer โ matched by id_from_ext; auto-created if not found (requires name) |
| partner_id.id_from_ext | string | Required | External ID of customer |
| partner_id.name | string | Optional | Used to auto-create partner if not found |
| name | string | Optional | Credit note number e.g. RINV/2026/RF/001. Set on create only. |
| invoice_date | date | Optional | Format: YYYY-MM-DD |
| invoice_date_due | date | Optional | Due date. Format: YYYY-MM-DD |
| ref | string | Optional | Source document reference |
| narration | string | Optional | Internal notes |
| pet_name | string | Optional | Patient/pet name (custom field) |
| warehouse_id | string | Optional | id_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_id | string | Required | id_from_ext of the original invoice being reversed. If not found in Odoo, the record is skipped. |
| global_discount_amount | number | Optional | Positive value. Adds a Global Discount line. Amount deducted from payment. |
| point_earning | number | Optional | Points originally earned on the invoice โ will be deducted. JE: Debit Payable, Credit Revenue Deduction. |
| point_earning_idr | number | Optional | IDR value of earn deduction. Used as JE amount. Analytic from warehouse_id.analytic_account_id. |
| point_redeem | number | Optional | Points originally redeemed on the invoice โ will be restored. JE: Debit Revenue Deduction, Credit Payable. |
| point_redeem_idr | number | Optional | IDR value of redeem restoration. Used as JE amount. Analytic from warehouse_id.analytic_account_id. |
| invoice_line_ids | array | Optional | Credit note lines โ upserted by id_from_ext. |
| payment_list | array | Optional | Outgoing payments to register. Only applied when state=posted and credit note has no existing payments. |
| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Optional | Match key for upsert |
| product_id | string | Optional | id_from_ext of product.template |
| name | string | Optional | Line description |
| quantity | number | Optional | Quantity. Default: 1.0 |
| price_unit | number | Optional | Unit price |
| discount | number | Optional | Discount percentage (0โ100) |
| discount_fixed | number | Optional | Fixed amount discount |
| price_subtotal | number | Optional | Validation only โ mismatch logged |
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.| Field | Type | Required | Description |
|---|---|---|---|
| payment_journal_code | string | Required | id_from_ext of outgoing account.payment.method.line. Determines both the journal and payment method. |
| amount | number | Required | Full refund amount. Also accepted as paid_amount. Stored as-is in paid_amount_ext. |
| date | date | Optional | Payment date. Also accepted as payment_date. Format: YYYY-MM-DD |
| memo | string | Optional | Payment note / communication |
| payment_type_name_ext | string | Optional | Stored in account.payment.payment_type_name_ext |
| payment_type_card_brand_ext | string | Optional | Card brand stored in account.payment.payment_type_card_brand_ext |
{
"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
}
data_type: "deposit" or "deposits" ยท Target model: account.payment (inbound) ยท Create-only by id_from_ext
id_from_ext in the invoice's payment_list[].payment_id_from_ext.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.| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency key. Already-existing records are skipped. |
| amount | number | Required | Deposit amount |
| payment_journal_code | string | Required | id_from_ext of an Inbound account.payment.method.line. Determines journal and payment method. |
| partner_id | object | Optional | Customer โ matched by id_from_ext; auto-created if not found (requires name) |
| partner_id.id_from_ext | string | Optional | External ID of customer |
| name | string | Optional | Voucher name โ overrides auto-generated JE name |
| date | date | Optional | Payment date. Format: YYYY-MM-DD |
| memo | string | Optional | Payment communication / reference |
| warehouse_id | string | Optional | id_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_ext | string | Optional | Stored in account.payment.payment_type_name_ext |
| payment_type_card_brand_ext | string | Optional | Card brand stored in account.payment.payment_type_card_brand_ext |
payment_list[].payment_id_from_extpayment_list entry with payment_id_from_ext (instead of payment_journal_code) in the invoice JSON. The deposit must exist and be posted.| Field | Type | Required | Description |
|---|---|---|---|
| payment_id_from_ext | string | Required | id_from_ext of the account.payment deposit to apply |
| amount | number | Optional | Amount to apply. Capped to: min(deposit available balance, invoice residual). If omitted, uses full available balance. |
| date | date | Optional | Transfer JE date |
| memo | string | Optional | Transfer JE reference note |
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"
}
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"
}
]
}
amount_residual = 0, payment_state = in_payment.data_type: "deposit_refund" or "deposit_refunds" ยท Target model: account.payment (outbound) ยท Create-only by id_from_ext
id_from_ext already exists are skipped (counted as updated).payment_journal_code must reference an Outgoing Payments method line.
| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency key. Already-existing records are skipped. |
| amount | number | Required | Refund amount |
| payment_journal_code | string | Required | id_from_ext of an Outgoing account.payment.method.line. Determines journal and payment method. |
| original_deposit_id | string | Required | id_from_ext of the original inbound deposit to reconcile against. Record is skipped if not found in Odoo. |
| partner_id | object | Optional | Customer โ matched by id_from_ext; auto-created if not found (requires name) |
| partner_id.id_from_ext | string | Optional | External ID of customer |
| name | string | Optional | Voucher name โ overrides auto-generated JE name |
| date | date | Optional | Payment date. Format: YYYY-MM-DD |
| memo | string | Optional | Payment communication / reference |
| warehouse_id | string | Optional | id_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_ext | string | Optional | Stored in account.payment.payment_type_name_ext |
| payment_type_card_brand_ext | string | Optional | Card brand stored in account.payment.payment_type_card_brand_ext |
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"
}
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
}
reconciled = True.amount less than the original deposit; the remaining balance stays unreconciled and can be applied to future invoices or refunded later.
data_type: "loyalty_history" ยท Target models: loyalty.card + loyalty.history ยท Create-only by id_from_ext
id_from_ext already exists are silently skipped โ no update is performed. This prevents duplicate journal entries on re-sync.issued > 0 โ Earn JE: Debit Revenue Deduction account, Credit Payable accountused > 0 โ Redeem JE: Debit Payable account, Credit Revenue Deduction accountcompany.loyalty_non_invoice_analytic_account_id). Applied to both JE lines when configured.| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency key. Already-existing records are skipped. |
| partner_id | string | Required | External 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_date | string | Optional | Date 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) |
| description | string | Optional | Text description stored on the history record and used as journal entry narration |
| issued | number | Optional | Loyalty points earned. Stored in loyalty.history.issued; added to loyalty.card.points |
| issued_idr_ext | number | Optional | IDR value of earned points. Stored in loyalty.history.issued_idr_ext; used as earn journal entry amount |
| used | number | Optional | Loyalty points redeemed. Stored in loyalty.history.used; subtracted from loyalty.card.points |
| used_idr_ext | number | Optional | IDR value of redeemed points. Stored in loyalty.history.used_idr_ext; used as redeem journal entry amount |
| referral_id_ext | string | Optional | Referral ID. Also accepted as referal_id_ext (without double-l). |
| user_id_ext | string | Optional | External user / member ID |
| user_name_ext | string | Optional | External user name. Used to auto-create partner when partner_id is not found. |
| email_ext | string | Optional | Email address from external system |
| xendit_id_ext | string | Optional | Xendit transaction ID for cross-referencing payment records |
| Record has | Earn history id_from_ext | Redeem 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 |
{
"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"
}
data_type: "wellness_package" or "wellness_packages" ยท Creates sale.order โ account.move โ account.payment ยท Create-only by id_from_ext
id_from_ext already exists as a sale.order are silently skipped โ no update is performed.wellness_warehouse_id โ warehouse used for all wellness SOswellness_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)| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency key โ skipped if a sale.order with this value already exists |
| name | string | Optional | Order number โ used as SO name and invoice ref |
| partner_id | object | Required | Customer 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_ext | string | Optional | External customer ID (primary lookup key) |
| partner_id.name | string | Optional | Required only when auto-creating a new partner |
| partner_id.mobile_user_id | string | Optional | Mobile app user ID โ stored on res.partner.mobile_user_id; updated even when partner already exists |
| partner_id.* | string | Optional | Standard contact fields: phone, mobile, email, street, street2, city, zip, ref, comment, state_id (match by name), country_id (match by ISO code) |
| description | string | Optional | Line item text โ combined with expired_at to form the SO/invoice line name: "{description} DD/MM/YYYY" |
| expired_at | datetime | Optional | Package expiry date โ formatted DD/MM/YYYY and appended to line name |
| transaction_date | date | Optional | YYYY-MM-DD โ used as SO date_order and invoice invoice_date |
| amount | float | Required | Payment amount โ already deducted by voucher, NOT by point_redeem. SO line price = amount + voucher_amount. Must be > 0. |
| point_earning | float | Optional | Loyalty points earned โ triggers earn JE (same logic as invoice) and loyalty card update |
| point_earning_idr | float | Optional | IDR value for the earn journal entry amount |
| point_redeem | float | Optional | Loyalty points redeemed โ added as a negative invoice line; deducted from payment amount |
| point_redeem_idr | float | Optional | IDR value of redeemed points โ deducted from payment: actual_payment = amount โ point_redeem_idr |
| voucher_amount | float | Optional | Voucher deduction โ added as a negative invoice line using voucher_product_id; already pre-deducted from amount |
| voucher_code | string | Optional | Voucher code โ used as the label/name of the voucher line item |
| payment_journal_code | string | Optional | id_from_ext of an inbound account.payment.method.line. Required when actual_payment > 0. Set via Accounting โ Journals โ Incoming Payments โ External ID column. |
{
"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"
}
wellness_product_id config; line name = "{description} DD/MM/YYYY"; price_unit = amount + voucher_amount (full price before voucher); qty = 1wellness_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 = postedactual_payment = amount โ point_redeem_idr; journal from payment_journal_code; reconciled with invoicecurl -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"
}'
data_type: "wellness_redemption" or "wellness_redemptions" ยท Creates a posted account.move (Journal Entry) ยท Create-only by id_from_ext
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.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)| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency/lookup key. On create: skipped if JE already exists. On cancel: used to find the original JE to reverse. |
| redeem | boolean | Optional | Default true. true = create a new redemption JE. false = cancel: post a reversal JE for the existing JE identified by id_from_ext. |
| partner_id | object | Optional | Customer dict โ matched by id_from_ext, auto-created if not found (requires name). Not required when redeem=false. |
| partner_id.id_from_ext | string | Optional | External customer ID |
| partner_id.name | string | Optional | Required only when auto-creating a new partner |
| product_id | string | redeem=true | id_from_ext of a product.template in Odoo โ must exist. Not required when redeem=false. |
| product_barcode | string | Optional | Product barcode from Digitail โ used in JE ref and line description |
| product_name | string | Optional | Product name from Digitail โ used in JE ref and line description |
| transaction_date | date | Optional | YYYY-MM-DD โ the redemption date; used as JE date. Sourced from redeemed_at on Digitail side. |
| appointment_id | string | Optional | Appointment ID from Digitail โ used in ref/line description: Ticket Redeem on {appointment_id} for {barcode} - {product_name} |
| amount | float | Required | Visit type amount based on the linked product. Must be > 0. Used for both debit and credit lines. |
| description | string | Optional | Used as JE narration (internal note) |
{
"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"
}
{
"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
}
redeem=true (create):
wellness_sale_journal_id; date = transaction_date; ref = Ticket Redeem on {appointment_id} for {barcode} - {product_name}wellness_accrued_account_id; amount = amountwellness_revenue_account_id; amount = amount; analytic_distribution = wellness_analytic_account_id (100%) if configuredredeem=false (cancel):
id_from_ext in account.move (entry)_reverse_moves() โ debit/credit swapped; date = transaction_date; posted immediatelyref 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)partner_id is explicitly copied from the original JE (written after reversal creation, before posting)reversed_entry_id on the reversal points back to the original JE (set automatically by Odoo)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"
}'
data_type: "membership_purchase" or "membership_purchases" ยท Creates Sale Order โ Invoice โ Payment ยท Create-only by id_from_ext
id_from_ext already exists as a sale.order are silently skipped.membership_sale_journal_id โ Sales journal (type: sale) used for the invoicemembership_product_id โ Service product used as the SO/invoice line itemmembership_payment_method_id โ Inbound payment method line for registering paymentmembership_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)| Field | Type | Required | Description |
|---|---|---|---|
| id_from_ext | string | Required | Idempotency key โ skipped if a sale.order with this value already exists |
| name | string | Optional | Order/transaction number โ stored as SO name and invoice ref |
| partner_id | object | Required | Customer dict โ resolved by id_from_ext โ mobile_user_id โ auto-created |
| partner_id.id_from_ext | string | Optional | External customer ID (primary lookup key) |
| partner_id.mobile_user_id | string | Optional | Mobile app user ID โ fallback lookup; updated on res.partner if partner already exists |
| partner_id.name | string | Optional | Required only when auto-creating a new partner |
| transaction_date | date | Optional | YYYY-MM-DD โ used as SO date_order and invoice_date |
| amount | float | Required | Transaction amount. Must be > 0. Used as SO line price_unit. |
| membership_start_date | date | Required | YYYY-MM-DD โ deferred revenue start date on invoice line; record skipped if missing |
| membership_end_date | date | Required | YYYY-MM-DD โ deferred revenue end date on invoice line; record skipped if missing |
| description | string | Optional | Used as SO/invoice line name |
{
"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"
}
transaction_date; SO name = name; product = membership_product_id; line price = amount; line name = description_create_invoices(); journal = membership_sale_journal_id (sale type); deferred start/end = membership_start_date / membership_end_date; analytic on line + receivableaction_post()membership_payment_method_id; amount = amountcurl -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"
}'
examples/claude_should_not_touch_this/ โ run steps in order.
Each step uses local: true so no S3 required.
| What | Where in Odoo | Value 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) |
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.amount is the pre-deduction total. The ETL subtracts the global discount line value internally before registering the payment.
CONSU-001)
type=consu with a private UOM category (Unit / Box(4)). Category Dog Food โ Pet Food is created if missing.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
}'
{
"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"
}
SVC-001)
type=service. Has additional_products referencing CONSU-001 โ Step 1 must run first. deleted_at is empty so the product stays active.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
}'
{
"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": ""
}
SO-2026-001)
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).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
}'
{
"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
}
INV-2026-001)
SO-2026-001 via matching id_from_ext on each line (SOL-001-01, SOL-001-02).payment.amount before the wizard runs:actual_payment = 1,035,000 โ 85,000 (35,000 + 50,000) = 950,000curl -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
}'
{
"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)
}
LH-2026-001)
CUST-001. Creates the loyalty card if it doesn't exist yet.issued: 5000 โ card balance +5000 pts. used: 0 โ no redemption entry.id_from_ext will skip.
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
}'
{
"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"
}
REFUND-2026-001)
INV-2026-001. Requires the original invoice to be fully paid (in_payment or paid).โ50,000) automatically from point_redeem_idr.actual_payment = 1,035,000 โ 50,000 (redeem) = 985,000.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.
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
}'
{
"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)
}
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).data_type: "deposit"). Then, create the invoice (data_type: "invoice") with a payment_list entry that references the deposit via payment_id_from_ext.residual = 0. Remaining deposit balance = 200,000.
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
}'
{
"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"
}
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
}'
{
"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"
}
]
}
amount_residual = 0."Deposit DEP-2026-001 applied to INV-2026-DEP-001".X-API-Key or Authorization: Bearer headerEnqueue a sync job. Returns immediately with a log_id.
| Body Field | Type | Required | Description |
|---|---|---|---|
| data_type | enum | Required | "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_location | string | Required | S3 path (bucket/prefix or bucket/path/to/file.json) or local path when local=true |
| local | boolean | Optional | Default false. When true: reads from local filesystem, writes result CSV next to input file. |
{"status": "queued", "log_id": 42}
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\"}]"
}
List objects at an S3 prefix. Useful for debugging S3 path resolution before triggering a sync.