Stripeaccounting/en

De SILADEL
Aller à : navigation, rechercher


Doc en Français


StripeAccounting is a Dolibarr module for Stripe accounting reconciliation.

It reads the Stripe API (or a CSV export), identifies the movements the official Stripe module does not post — fees, refunds, disputes, payouts — and creates the missing accounting entries.

Compatibility — Dolibarr 17+, PHP 7.2 → 8.5
Depends on — Stripe, Bank and Double-entry Accounting modules
See alsoInstallation and user guide

1. Design principles

Four rules govern the whole module.

Principle What it means
Complementary The module creates no payment, no invoice, no webhook. It reads Stripe and completes the books, nothing more.
Idempotent Each Stripe object is processed once only, no matter how many times the sync is re-run.
Never guess If an operation cannot be matched with certainty, nothing is posted: the row goes to error for manual review.
Configuration is blocking As long as the minimum configuration is incomplete, no sync runs at all (no button, no cron, no import).

2. Requirements

Required Dolibarr modules

Module What it provides
Stripe (modStripe) API key, test/live mode, Stripe financial account
Bank (modBanque) llx_bank and llx_bank_account tables
Double-entry accounting (modAccounting) llx_accounting_bookkeeping table

Mandatory Dolibarr setting

ACCOUNTING_ACCOUNT_TRANSFER_CASH must be set under Accounting → Setup → Miscellaneous accounts. Without it, the internal transfer generated by payouts cannot be transferred to accounting (a Dolibarr limitation, not a module one).

Notes

  • The Stripe PHP SDK is not re-vendored: the module reuses the core copy (includes/stripe/stripe-php/).
  • The code deliberately avoids any PHP syntax newer than 7.2 (no match, ?->, enums, typed properties, str_contains…) so it runs across the whole 7.2 → 8.5 range.

3. File tree

custom/stripeaccounting/
├── core/modules/modStripeAccounting.class.php   Module descriptor (permissions, menus, cron, constants)
├── class/
│   ├── StripeApi.class.php                      Stripe SDK wrapper (read-only)
│   ├── StripeRepository.class.php               SQL access + idempotency
│   ├── StripeLogger.class.php                   Logging
│   ├── StripeAccounting.class.php               Accounting entry generation
│   ├── StripePayout.class.php                   Payout reconciliation
│   ├── StripeSync.class.php                     Orchestrator (cron entry point)
│   ├── StripeCsvImporter.class.php              CSV import
│   └── actions_stripeaccounting.class.php       'bankcard' hook
├── admin/setup.php                              Configuration
├── import.php                                   CSV import
├── dashboard.php                                Dashboard
├── transaction_list.php                         Transaction list
├── cron/stripe_sync.php                         CLI entry point
├── lib/stripeaccounting.lib.php
├── sql/
├── langs/{fr_FR,en_US,es_ES,it_IT,de_DE}/
└── css/

Dependency direction

pages and hook → StripeSync → (StripeApi, StripeRepository, StripeAccounting, StripePayout) → StripeLogger
Class Role
StripeApi Queries the Stripe SDK (balance transactions, charges, refunds, disputes, payouts) with automatic pagination. No database access, no accounting entries.
StripeRepository All SQL for the module's two tables. upsertTransaction() is the idempotency anchor.
StripeLogger Writes to llx_stripeaccounting_log and mirrors to dol_syslog().
StripeAccounting Builds accounting entries (refund, dispute, reverse-charge VAT) or bank movements (fees). Exposes checkConfig().
StripePayout Creates the internal bank transfer between the Stripe account and the real bank account.
StripeSync Orchestrator: one method per flow, plus reconcileStripeAccount() which chains them all.
StripeCsvImporter Feeds the same pipeline from a Stripe CSV export.
ActionsStripeAccounting bankcard hook: dashboard link on the Stripe account record.

4. Synchronisation and idempotency

Every flow (syncFees, syncRefunds, syncDisputes, syncPayouts, syncTransactions) always follows the same steps:

  1. Check the configuration — if incomplete: ERROR log, immediate stop, no API call at all.
  2. List Stripe objects over a rolling window of 35 days.
  3. Filter — an object is skipped only if it already exists and its status is reconciled or ignored. A row in pending or error is retried on this run.
  4. Record it as pending, then generate the accounting entry or bank movement.
  5. Conclude — success: status reconciled. Failure: status error with the message, and the batch carries on with the next object.

Automatic retry — a row in error becomes eligible again on the next run. Once the cause is fixed (a fiscal year created late, for instance), no manual action is needed.

Why duplicates are impossible

  • Unique database key on (stripe_object_id, entity) — protects even against two concurrent cron executions.
  • Explicit check before processing — avoids even attempting to post an entry for an already-finalised object.

Special cases: adjustment and payment_failure_refund

These are balance corrections Stripe makes on its own (reversal after a dispute is resolved, automatic refund of a payment that ultimately failed). The module records them — otherwise they would stay invisible — but never posts them automatically: the sign of the amount and the underlying cause vary too much. They are created directly in error, with an explicit message, to be handled manually.

5. Accounting entries

All lines of a single Stripe event share the same doc_type='stripeaccounting', fk_doc and doc_ref triplet: Dolibarr automatically groups them under one piece number, so the module needs no numbering scheme of its own.

Overview

Event What the module does Resulting entry
Stripe fee (VAT mode none or autoliquidation) PaymentVarious bank movement 627xxx / 517xxx entry generated later by Dolibarr
Stripe fee (autoliquidation mode) Plus: VAT pair posted immediately Debit deductible VAT, credit reverse-charge VAT (net impact nil)
Stripe fee (french mode) Full direct entry Debit fee account excl. VAT + deductible VAT, credit Stripe account incl. VAT
Subscription / service fees (Radar, Billing, Connect…) PaymentVarious bank movement Entry generated later by Dolibarr, on the Stripe supplier account
Payout Internal bank transfer between the two accounts Entry generated later by Dolibarr
Refund (invoice found) Direct entry Debit customer account 411xxx, credit Stripe account 517xxx
Refund (invoice not found) Nothing Status error, handle manually
Dispute / chargeback Direct entry Debit dispute loss 658xxx, credit Stripe account 517xxx (+ fee account if fees apply)

Why fees and payouts produce no direct entry

The historical problem: when a Stripe-paid invoice is transferred to accounting through Dolibarr's standard workflow, the payment is posted at its gross amount — fees are ignored at that stage.

If the module posted its own entry and its own bank line on top of that, this line would stay invisible to bankjournal.php — which only recognises lines attached to doc_type='bank'. An administrator transferring the bank account to accounting "normally" would then create a second entry for the very same movement.

The chosen solution: for fees, subscription fees and payouts, the module now creates only the real bank movement, using Dolibarr's native mechanisms. The native "Transfer to accounting" button then produces the entry, exactly as for any other bank movement. In those three cases, the module no longer touches llx_accounting_bookkeeping at all.

Case Native mechanism used
Fees and subscription fees PaymentVarious (bank movement with an explicit counterpart account, no third party or invoice), carrying its own accountancy_code, which bankjournal.php reads directly.
Payouts Dolibarr internal transfer: Account::addline() on both sides, plus add_url_line(…, 'banktransfert'). The counterpart is posted to ACCOUNTING_ACCOUNT_TRANSFER_CASH.

In both cases the status becomes reconciled — the module's job is done — but piece_num stays empty: the entry does not exist yet, only fk_bank is set. After the manual transfer, it can be found with doc_type='bank' and fk_doc equal to the row's fk_bank.

Worth knowing — the payout entry only balances to zero on the transfer account once both bank accounts (Stripe and the real bank) have been transferred to accounting, not just one of them.

STRIPE_AUTO_RECORD_PAYOUT must stay disabled

Once this module handles payouts, untick "Enable automatic recording of Stripe payments" in stripe/admin/stripe.php — despite its wording, that checkbox only concerns payouts.

Otherwise the official module's webhook and this module would each create their own bank movement for the same payout, unaware of each other.

The core webhook keeps no trace of the payout id it has processed: should Stripe deliver the event twice (delivery is guaranteed "at least once"), nothing stops it from duplicating on its own either. This module has real idempotency and automatically catches up any missed payout within 35 days.

The "Stripe transfers" page is hidden

On activation, modStripeAccounting::init() disables the stripe/payout.php menu entry: that page queries the API live without recording anything, and having it alongside this module would only create confusion about which one is authoritative for payouts. remove() restores the original condition. This is the only core table the module modifies — deliberate, and documented here for that reason.

Per-transaction fees or subscription fees?

Stripe bills two distinct things under the generic word "fees":

Per-transaction fees Subscription / service fees
Example Commission on each payment Radar, Billing, Connect
Where to find it The .fee field of a charge balance transaction A dedicated balance transaction, type stripe_fee or application_fee (its .fee is always 0, the amount sits in .amount)
Account used The configured fee account (627xxx) The supplier accounting code of the configured Stripe third party
Type in database fee subscription_fee

Limitation of the french VAT mode

This mode has to split the gross amount into net + VAT, something PaymentVarious cannot express. It therefore keeps the previous behaviour: a full direct entry, with no bank movement created. No duplicate risk (precisely because no bank line is produced), but the llx_bank balance of the Stripe account will never reflect the fees while this mode is active.

6. CSV import

The import covers the case of a Stripe account different from the one configured in the official module — another PrestaShop shop with its own merchant account, for example. The API has no access to it: its contents are exported to CSV from the Stripe dashboard, then imported.

Starting assumption

The invoices and payments of that shop are already posted in Dolibarr through some other process, and the collection (debit 517, credit 411) is already booked. Only the fees, refunds and payouts are missing — which is exactly what the import handles.

Interface: a single field

import.php now exposes only one upload field, General history, wired to importGeneralHistory(). That file alone covers every movement type.

importPayments() and importPayouts() are still implemented and functional, but no longer reachable from the interface; they are documented below for reference.

Like the "Sync now" button, the import is blocked — warning banner, disabled button, server-side guard — while the configuration is incomplete.

The three methods

Method Stripe export read Processing
importGeneralHistory()
(the only one exposed)
Balance history Dispatch on the Type column, see table below
importPayments() Payments Fee → fee movement. Amount refunded → refund row, always in error.
importPayouts() Payouts Exactly the same processing as a payout coming from the API

General history dispatch, by Type column:

Stripe type Processing
payout Internal bank transfer, as via the API
charge / payment with a fee Fee movement
stripe_fee / application_fee Subscription fee, on the Stripe supplier account
refund Recorded, always in error
payout_minimum_balance_hold / …_release Ignored — Stripe rolling reserve, no real cash movement, nothing to post
any other type Recorded as type='other', status error, raw Stripe type kept in the description. No guessed entry.

Columns are matched by name: their order does not matter, and extra unused columns are ignored.

Import idempotency

The unique key protects against any double import: re-importing the same CSV, or one overlapping a previous import, creates no duplicate.

An important detail: in the general history, payout and charge/payment rows are indexed by their Source column (po_…, ch_…), not by the row's own id (txn_…) — the very same key the dedicated imports use. Importing the general history on top of the specific exports therefore never posts the same entry twice, in any order.

Since Stripe identifiers are unique platform-wide, mixing rows from the API account and from several CSV-imported accounts in the same table carries no collision risk.

Practical details

  • Source label — a free-text field entered at import time (e.g. "DRM shop") is stored on every created row and serves as a column and filter in the list, to tell several shops apart side by side.
  • Amount format — Stripe CSVs use the French decimal comma, converted through price2num(). Dates marked (UTC) are interpreted as such.

7. Configuration

The API key and test/live mode are not re-entered here: they are read, read-only, from the official Stripe module.

Field Constant Purpose
Stripe financial account STRIPE_BANK_ACCOUNT_FOR_PAYMENTS (official module constant) Bank account selector. No local copy, hence no risk of divergence.
Main bank account STRIPE_BANK_ACCOUNT_FOR_BANKTRANSFERS (official module constant) Same selector. See the box below.
Fee account STRIPEACCOUNTING_COMPTE_FRAIS Account number, e.g. 627xxx
Dispute account STRIPEACCOUNTING_COMPTE_LITIGES Account number, e.g. 658xxx. Required — used as soon as a dispute occurs, whatever the VAT mode.
Stripe supplier third party STRIPEACCOUNTING_FOURNISSEUR_STRIPE_ID A third party flagged as supplier; its supplier accounting code is what gets used. Optional, unless subscription fees exist.
VAT mode STRIPEACCOUNTING_VAT_MODE none, french or autoliquidation
Deductible VAT account (intra-EU) STRIPEACCOUNTING_COMPTE_TVA_DEDUCTIBLE Required when the VAT mode is not none. Stripe always invoices from a non-French EU entity, so the deductible VAT is intra-EU in every case.
VAT due account (intra-EU) STRIPEACCOUNTING_COMPTE_TVA_AUTOLIQUIDATION Required in autoliquidation mode only — never used in french mode.
VAT rate STRIPEACCOUNTING_VAT_RATE Percentage, 20 by default
Sync frequency STRIPEACCOUNTING_SYNC_UNITFREQUENCY Applied to the scheduled job (in seconds)

Why redefine the main bank account here?

The official Stripe module's setup page only renders that field when STRIPE_AUTO_RECORD_PAYOUT is enabled — and its own save handler resets the value to 0 on every save while the field is not rendered. Yet that setting must stay disabled once this module handles payouts. The field is therefore re-exposed here, writing the core constant directly, so the value stays configurable and stable.

Both account selectors only write their constant when a real account was submitted: saving the page for some unrelated field never overwrites a correct value.

Configuration check

StripeAccounting::checkConfig() returns the list of missing constants. It is called both by the setup page (information banner) and by every sync entry point (the real guard that blocks all processing).

Pitfall with accounting account selectors

The four fields using FormAccounting::select_account() (fees, disputes, both VAT accounts) have a "nothing selected" option whose value is -1 — a non-empty string, therefore truthy in PHP. A field left blank and saved literally stores -1.

StripeAccounting::getAccountConst() normalises that value to an empty string on read, everywhere these constants are read. No entry can therefore land on the non-existent account -1.

8. User interface

Dashboard

Stripe balance (live API call), number of unreconciled transactions, total fees for the month, pending payouts, and a Sync now button — disabled, with a banner listing the missing constants, while the configuration is incomplete.

Transaction list

A paginated, sortable, filterable list built with the same mechanism as native Dolibarr lists: the icon at the top right of the table lets each user pick their columns, and the preference is stored per user.

Column Content
Amount, Fee, Total The fee popup shows the VAT on the fee whenever a reverse-charge pair or French VAT was posted
Type, Description Popup with the Stripe ID, the event or source ID, and the full untruncated description
Third party The customer resolved through the invoice, or through the source charge for its related fee, or the configured Stripe supplier for a subscription fee
Entry A "Piece no. X" link to the accounting entry, and/or a link to the related bank movement — see the caveat below
Source, Status The error badge opens a popup with the full error message: no need to query the database to understand a failure

Reading the "Entry" column — on a fee, subscription_fee or payout row, the link points to the bank movement, not to an accounting piece. The module's piece_num is never the 627/517 entry for the fee: it is only the reverse-charge VAT pair. The fee entry itself is generated later, by Dolibarr's native button, and is never reported back onto this row.

The page is purely informational: no manual posting is offered there. The module posts automatically, and the actual transfer to accounting for fees and payouts happens through the native "Transfer to accounting" button.

charge and fee rows

Each payment produces two rows in the database:

  • a charge row, always in status ignored, fee 0, never processed — present solely for idempotency;
  • a fee row for the same payment, linked to the first one.

Both appear separately in the list, but the fee row also carries the link to the payment and the invoice, resolved through a join.

Test mode banner

While live mode is off, the dashboard, the setup page and the transaction list all display the same warning as the official Stripe module. It is impossible to forget you are working in test mode.

Menus

Everything sits under Banks and cash → Stripe account (the official module's menu), flat, as three entries at the same level:

  1. Stripe dashboard
  2. Stripe transactions
  3. CSV import (administrators only)

A dashboard link is also added automatically on the bank account record configured as the Stripe account.

9. Scheduled job

There are two ways to run the sync, both idempotent and safe to re-run. In both cases the code executed is strictly the same: no behavioural difference between the two triggers.

⚠ Neither of them starts on its own.

Activating the module creates the StripeAccountingSync job, but disabled by default. And even once enabled, the Dolibarr scheduler is triggered by nothing until a system crontab calls it. Without both steps, the sync only happens when "Sync now" is clicked manually.

Step 1 — enable the job

Under Home → Setup → Modules → Scheduled jobs. Since llx_cronjob is a core table, this setting is made through the interface only, never by direct SQL from the module.

Step 2 — a system trigger

Pick either option.

Option A — Dolibarr scheduler (recommended when several Dolibarr scheduled jobs coexist). Runs every due job, not just this one. Requires step 1.

*/15 * * * * curl "https://your-dolibarr/public/cron/cron_run_jobs_by_url.php?securitykey=YOUR_KEY&userlogin=YOUR_LOGIN"

The security key is generated on the scheduled jobs page, "Security of job execution by URL" button.

Option B — the module's CLI script. Standalone, refuses to run from a browser, and independent of whether the Dolibarr job is enabled.

*/15 * * * * php /path/to/htdocs/custom/stripeaccounting/cron/stripe_sync.php >> /var/log/stripeaccounting.log 2>&1

10. Logging and permissions

Logging

Every API call, generated entry, warning or error is traced in llx_stripeaccounting_log, readable in the database or through the standard Dolibarr log file. Levels: DEBUG < INFO < WARNING < ERROR. The context column holds the structured detail as JSON (API payload, computed amounts…).

Permissions

Right Controls
read Access to the dashboard and the transaction list
write Declared for configuration, but not checked in the code today
reconcile Manual sync triggering

In practice, admin/setup.php and import.php only check Dolibarr administrator status, not the write right: only administrators can configure the module or import a CSV, whatever is ticked in the module permissions.

11. Database schema

llx_stripeaccounting_transaction

One row per processed Stripe object — charge, fee, refund, dispute or payout — all in the same table, distinguished by the type column.

Column Type Role
rowid int AI Primary key
entity int Dolibarr multi-company
stripe_object_id varchar(80) Stripe identifier — idempotency anchor
stripe_event_id varchar(80) Event identifier, informational only
type varchar(20) charge, fee, subscription_fee, refund, dispute, payout, other
stripe_source_id varchar(80) Source Stripe object (e.g. the charge behind a refund)
source_label varchar(80) Free-text label entered at CSV import; empty for API rows
description varchar(255) Stripe label
amount, fee, net decimal(24,8) Amounts already converted from Stripe cents
currency varchar(3) ISO currency code
date_created, date_available_on datetime Stripe-side dates
fk_facture, fk_paiement int Resolved Dolibarr invoice and payment, if found
fk_bank int Generated llx_bank line (empty for a fee in french VAT mode)
piece_num varchar(20) Generated accounting piece number, where there is one
status varchar(16) pending, reconciled, error, ignored
error_message text Detail on failure
date_creation, date_processed datetime Technical timestamps
fk_user_creat int User who triggered the processing

Unique key on (stripe_object_id, entity) — this is what guarantees the same Stripe object can never be inserted twice, even with concurrent executions.

llx_stripeaccounting_log

Column Role
level DEBUG, INFO, WARNING, ERROR
message Short message
context Structured context as JSON
fk_stripeaccounting_transaction Related transaction, where applicable
datec Timestamp

12. Troubleshooting

Symptom Likely cause Action
The "Sync now" button is disabled Minimum configuration incomplete Fill in the fields listed in the setup page banner
A transaction stays in error Entry rejected by Dolibarr (non-existent account, closed period), or refund not matched Read error_message on the row, then the detail in the log table
The Stripe balance is not displayed API key missing or invalid in the official Stripe module Check stripe/admin/stripe.php
A refund is never reconciled The original payment was not found through ext_payment_id Check that the Stripe payment was recorded by the official module with that identifier
A subscription_fee stays in error Stripe supplier third party not set, or has no supplier accounting code Fill the field in setup, and complete the third party record
A CSV-imported refund stays in error Normal and expected — that account is not linked to ext_payment_id Handle the invoice and refund manually in accounting

13. Pitfalls encountered

Development notes, kept so nobody falls into them again.

  • PaymentVarious::create() only updates fk_bank in the database, never on the PHP object. A fetch() after create() is required to get the real value.
  • select_comptes() prints its own HTML and returns the number of accounts. Call it as a bare statement: wrapping it in print would display that number right after the select tag.
  • master.inc.php, not main.inc.php for the CLI script. The latter expects an HTTP session and, in pure CLI, exits silently — no output, no log, return code 0 — inside its redirect to the login page. This gives the misleading impression that the script "does nothing" rather than a real error.
  • Core librariesmaster.inc.php does not load everything main.inc.php loads for the web (e.g. core/lib/date.lib.php, required by BookKeeping). Include them explicitly if the CLI script evolves.
  • The "nothing selected" option is -1 in accounting account selectors — see the Configuration section.

14. Known limitations

  • Zero-decimal currencies (JPY, KRW…) are not handled: amounts are divided by 100 assuming two decimals. Intended use: EUR.
  • The 35-day window is not configurable. A sync interrupted for longer must be caught up manually.
  • Refund matching — relies on ext_payment_id and ext_payment_site in llx_paiement, filled in by the standard Stripe payment flow. A payment recorded any other way cannot be matched automatically. Matching goes through the PaymentIntent id, never the charge id: the core Stripe module never stores the latter (it stores either the raw PaymentIntent, or a composite value, depending on the payment path taken).
  • CSV import assumes French notation: an export with a decimal point would not be parsed correctly.
  • The "Transfer" column of the Payments CSV is stored for information only: it is not used to check that the sum of payments matches the imported payout amount.
  • CSV-imported refunds always land in error — intended behaviour, not a bug.
  • french VAT mode — the Stripe account's bank balance does not reflect the fees, see section 5.