diff --git a/ordering.go b/ordering.go index 9a5dd040..63eeb083 100644 --- a/ordering.go +++ b/ordering.go @@ -129,9 +129,18 @@ func (ui *Invoice) addOrdering(o *bill.Ordering) { } for _, contract := range o.Contracts { - ui.ContractDocumentReference = append(ui.ContractDocumentReference, Reference{ + ref := Reference{ ID: IDType{Value: string(contract.Code)}, - }) + } + // BT-12 carries the contract reference (cbc:ID). The French CTC/Chorus + // Pro extension (EXT-FR-FE-01) also expects the contract type in + // cbc:DocumentType. contract.Type is a cbc.Key, so it is always + // lower-case (e.g. "marche"); upper-case values are rejected upstream + // by GOBL's key pattern validation. + if contract.Type != "" { + ref.DocumentType = contract.Type.String() + } + ui.ContractDocumentReference = append(ui.ContractDocumentReference, ref) } for _, tender := range o.Tender { diff --git a/ordering_test.go b/ordering_test.go index f7da17da..1a570c96 100644 --- a/ordering_test.go +++ b/ordering_test.go @@ -3,7 +3,11 @@ package ubl_test import ( "testing" + ubl "github.com/invopop/gobl.ubl" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewOrdering(t *testing.T) { @@ -15,4 +19,24 @@ func TestNewOrdering(t *testing.T) { assert.Equal(t, "NA", doc.OrderReference.ID) }) + // BT-12 (contract reference) plus the France CTC/Chorus Pro contract type + // (EXT-FR-FE-01) must both be serialized into ContractDocumentReference. + t.Run("contract type maps to cbc:DocumentType", func(t *testing.T) { + env := loadTestEnvelope(t, "invoice-complete.json") + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Ordering.Contracts = []*org.DocumentRef{ + {Code: "test nr de marche", Type: "marche"}, + } + + require.NoError(t, env.Calculate()) + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + + require.Len(t, doc.ContractDocumentReference, 1) + ref := doc.ContractDocumentReference[0] + assert.Equal(t, "test nr de marche", ref.ID.Value) + assert.Equal(t, "marche", ref.DocumentType) + }) } diff --git a/party.go b/party.go index c389198d..ed1dba9a 100644 --- a/party.go +++ b/party.go @@ -15,6 +15,31 @@ const SchemeIDEmail = "EM" // TaxSchemeVAT is the tax scheme code for VAT const TaxSchemeVAT = "VAT" +// identityKeyPrivateID is the GOBL identity key used for France's "code +// routage" / Chorus Pro "Code Service", and identitySchemeIDRoutage is the +// ISO 6523 scheme (0224) it maps to. The France CTC addon sets the +// iso-scheme-id extension to 0224 during normalization, but the Factur-X +// (France Extended / B2G) addon is a placeholder and does not, so we derive +// the scheme here to ensure the buyer's code routage is serialized as +// PartyIdentification schemeID="0224" (BR-FR-CPRO-11) regardless of profile. +const ( + identityKeyPrivateID = "private-id" + identitySchemeIDRoutage = "0224" +) + +// identitySchemeID resolves the ISO 6523 scheme ID to use for a party +// identity. It prefers the explicit iso-scheme-id extension and falls back to +// known GOBL identity keys that map to a fixed scheme. +func identitySchemeID(id *org.Identity) string { + if s := id.Ext.Get(iso.ExtKeySchemeID).String(); s != "" { + return s + } + if id.Key == identityKeyPrivateID { + return identitySchemeIDRoutage + } + return "" +} + // SupplierParty represents the supplier party in a transaction type SupplierParty struct { Party *Party `xml:"cac:Party"` @@ -219,7 +244,7 @@ func newParty(party *org.Party) *Party { //nolint:gocyclo p.PartyLegalEntity.CompanyID = &IDType{ Value: code, } - if s := id.Ext.Get(iso.ExtKeySchemeID).String(); s != "" { + if s := identitySchemeID(id); s != "" { p.PartyLegalEntity.CompanyID.SchemeID = &s } firstLegalIdx = i @@ -256,7 +281,7 @@ func newParty(party *org.Party) *Party { //nolint:gocyclo idType := &IDType{ Value: id.Code.String(), } - if s := id.Ext.Get(iso.ExtKeySchemeID).String(); s != "" { + if s := identitySchemeID(id); s != "" { idType.SchemeID = &s } p.PartyIdentification = append(p.PartyIdentification, Identification{ diff --git a/party_test.go b/party_test.go index bf0e97c3..20522a12 100644 --- a/party_test.go +++ b/party_test.go @@ -69,4 +69,35 @@ func TestNewParty(t *testing.T) { require.NotNil(t, doc.PayeeParty.PartyLegalEntity.CompanyID.SchemeID) assert.Equal(t, "0088", *doc.PayeeParty.PartyLegalEntity.CompanyID.SchemeID) }) + + // France "code routage" / Chorus Pro "Code Service" is modelled as an + // identity with key "private-id". The CTC (B2B CIUS) addon sets the + // iso-scheme-id ext to 0224, but the Factur-X (France Extended / B2G) addon + // is a placeholder and does not. The customer identity must still be + // serialized as PartyIdentification schemeID="0224" (BR-FR-CPRO-11) here, so + // the scheme is derived from the key when the ext is absent. + t.Run("private-id key maps to scheme 0224 without ext", func(t *testing.T) { + env := loadTestEnvelope(t, "invoice-complete.json") + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + // No iso-scheme-id ext set, mirroring the Factur-X / Extended profile. + inv.Customer.Identities = []*org.Identity{ + {Key: "private-id", Code: "SERVICE-ACHATS-01"}, + } + + require.NoError(t, env.Calculate()) + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + + var found bool + for _, pid := range doc.AccountingCustomerParty.Party.PartyIdentification { + if pid.ID != nil && pid.ID.Value == "SERVICE-ACHATS-01" { + require.NotNil(t, pid.ID.SchemeID) + assert.Equal(t, "0224", *pid.ID.SchemeID) + found = true + } + } + assert.True(t, found, "expected code routage identity in PartyIdentification") + }) }