Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Added

- `sg`: UEN check-character validation now covers business, local-company, and
other-entity UENs, for both UEN identities and tax identity codes.
- `net`: added `SandboxAuthorities` (defaulting to `lookup.sandbox.gobl.org`)
and `WithSandbox`. Sandbox and live trust lists remain separate.

Expand All @@ -30,6 +32,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Fixed

- `sg`: tax identity codes accept IRAS-assigned GST registration numbers ending
in a digit (for example, `M201189853`).
- `head`: `SignedPayload` and `Header.Verify` now return an error for `null`
signature entries instead of panicking.

Expand Down
4 changes: 1 addition & 3 deletions examples/sg/invoice-gst-number.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"identities": [
{
Expand Down Expand Up @@ -51,6 +51,4 @@
]
}
]

}

5 changes: 2 additions & 3 deletions examples/sg/invoice-receipt.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
}
},
"lines": [
Expand All @@ -28,5 +28,4 @@
]
}
]

}
}
5 changes: 2 additions & 3 deletions examples/sg/invoice-simple.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"addresses": [
{
Expand All @@ -38,5 +38,4 @@
]
}
]

}
}
4 changes: 1 addition & 3 deletions examples/sg/invoice.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"addresses": [
{
Expand Down Expand Up @@ -45,6 +45,4 @@
]
}
]

}

4 changes: 2 additions & 2 deletions examples/sg/out/invoice-gst-number.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "8a51fd30-2a27-11ee-be56-0242ac120002",
"dig": {
"alg": "sha256",
"val": "74947258d585ebd9c7aa85faabddd006d2f7de405d5f282c1216b67af40f128e"
"val": "e29c5f4e3e6edc881569e43ec910d2e83af2efafe1da9ef5c63785e290bd1284"
}
},
"doc": {
Expand All @@ -19,7 +19,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"identities": [
{
Expand Down
4 changes: 2 additions & 2 deletions examples/sg/out/invoice-receipt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "8a51fd30-2a27-11ee-be56-0242ac120002",
"dig": {
"alg": "sha256",
"val": "6137fe1dff83bf55a48085f255bbc8059b2f9e3d4f1082483fa1a89a582cd00b"
"val": "6e84b526f70c2a8ca4b4d36b3974bb5899a4f8b8c6d1a3d127e331f8d22ad0b3"
}
},
"doc": {
Expand All @@ -19,7 +19,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
}
},
"lines": [
Expand Down
4 changes: 2 additions & 2 deletions examples/sg/out/invoice-simple.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "8a51fd30-2a27-11ee-be56-0242ac120002",
"dig": {
"alg": "sha256",
"val": "e2240ad7bef4e9eac7ac3bee35a3119e120c8d8f6b2c40017cd491771a021030"
"val": "4eb9444cb74ab5f2b7063083e20365a954eeae776ee76b2b7943a3db051b092d"
}
},
"doc": {
Expand All @@ -23,7 +23,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"addresses": [
{
Expand Down
4 changes: 2 additions & 2 deletions examples/sg/out/invoice.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uuid": "8a51fd30-2a27-11ee-be56-0242ac120002",
"dig": {
"alg": "sha256",
"val": "370f9f130b445765ea0d4d9eec22c551bde0760220878a2fa0b11816bd2068cd"
"val": "1cbbba2a8f2fd6c9e4466fe772ad3cefe6ea2ae1fceb109fa222ad5e7d3aa079"
}
},
"doc": {
Expand All @@ -19,7 +19,7 @@
"name": "EXAMPLE SUPPLIER",
"tax_id": {
"country": "SG",
"code": "201312345A"
"code": "201312345N"
},
"addresses": [
{
Expand Down
2 changes: 1 addition & 1 deletion regimes/sg/bill_invoices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestValidInvoiceWithUEN(t *testing.T) {
inv.Supplier.Identities = []*org.Identity{
{
Type: sg.IdentityTypeUEN,
Code: "199912345A",
Code: "197401143C",
},
}
require.NoError(t, inv.Calculate())
Expand Down
69 changes: 57 additions & 12 deletions regimes/sg/org_identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,33 @@ const (
)

var (
regexpsUENIdentities = []*regexp.Regexp{
regexp.MustCompile(`^(19[0-9]{2}|20[0-9]{2})\d{5}[A-Z]$`), // UEN (ROC)
regexp.MustCompile(`^\d{8}[A-Z]$`), // UEN (ROB)
regexp.MustCompile(`^[TS]\d{2}[A-Z]{2}\d{4}[A-Z]$`), // UEN (Others)
regexpUENBusiness = regexp.MustCompile(`^\d{8}[A-Z]$`)
regexpUENLocalCompany = regexp.MustCompile(`^(19\d{2}|20\d{2})\d{5}[A-Z]$`)
regexpUENOther = regexp.MustCompile(`^[RST]\d{2}[A-Z]{2}\d{4}[A-Z]$`)

// Entity types assigned to UENs for bodies other than businesses and
// local companies. See https://www.uen.gov.sg/ueninternet/faces/pages/admin/aboutUEN.jspx.
uenOtherEntityTypes = map[string]struct{}{
"CC": {}, "CD": {}, "CH": {}, "CL": {}, "CM": {}, "CP": {}, "CS": {}, "CX": {},
"DP": {}, "FB": {}, "FC": {}, "FM": {}, "FN": {}, "GA": {}, "GB": {}, "GS": {},
"HS": {}, "LL": {}, "LP": {}, "MB": {}, "MC": {}, "MD": {}, "MH": {}, "MM": {},
"MQ": {}, "NB": {}, "NR": {}, "PA": {}, "PB": {}, "PF": {}, "RF": {}, "RP": {},
"SM": {}, "SS": {}, "TC": {}, "TU": {}, "VH": {}, "XL": {},
}
)

const (
uenBusinessCheckAlphabet = "XMKECAWLJDB"
uenLocalCompanyCheckAlphabet = "ZKCMDNERGWH"
uenOtherCheckAlphabet = "ABCDEFGHJKLMNPQRSTUVWX0123456789"
)

var (
uenBusinessWeights = []int{10, 4, 9, 3, 8, 2, 7, 1}
uenLocalCompanyWeights = []int{10, 8, 6, 4, 9, 7, 5, 3, 1}
uenOtherWeights = []int{4, 3, 5, 3, 10, 2, 2, 5, 7}
)

var identityDefinitions = []*cbc.Definition{
{
Code: IdentityTypeUEN,
Expand Down Expand Up @@ -76,13 +96,38 @@ func orgIdentityCheckUEN(value any) bool {
if !ok || code == "" {
return false
}
val := code.String()
match := false
for _, re := range regexpsUENIdentities {
if re.MatchString(val) {
match = true
break
}
return validateUENCode(code.String())
}

// validateUENCode verifies a UEN's shape and its check character. The check
// character tables are the community reverse-engineered versions used by
// python-stdnum; ACRA does not publish the algorithm.
func validateUENCode(code string) bool {
switch {
case regexpUENBusiness.MatchString(code):
return code[8] == uenDigitCheck(code, uenBusinessWeights, uenBusinessCheckAlphabet)
case regexpUENLocalCompany.MatchString(code):
return code[9] == uenDigitCheck(code, uenLocalCompanyWeights, uenLocalCompanyCheckAlphabet)
case regexpUENOther.MatchString(code):
_, knownType := uenOtherEntityTypes[code[3:5]]
return knownType && code[9] == uenOtherCheck(code)
default:
return false
}
}

func uenDigitCheck(code string, weights []int, alphabet string) byte {
sum := 0
for i, weight := range weights {
sum += int(code[i]-'0') * weight
}
return alphabet[sum%len(alphabet)]
}

func uenOtherCheck(code string) byte {
sum := 0
for i, weight := range uenOtherWeights {
sum += strings.IndexByte(uenOtherCheckAlphabet, code[i]) * weight
}
return match
return uenOtherCheckAlphabet[(sum-5)%11]
}
11 changes: 8 additions & 3 deletions regimes/sg/org_identities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,19 @@ func TestValidateUENIdentity(t *testing.T) {
code string
err bool
}{
{name: "UEN (ROC)", code: "199912345A", err: false},
{name: "UEN (ROB)", code: "12345678A", err: false},
{name: "UEN (Others)", code: "T12AB1234A", err: false},
{name: "UEN (ROC)", code: "197401143C", err: false},
{name: "UEN (ROB)", code: "00192200M", err: false},
{name: "UEN (Others)", code: "T08GB0020K", err: false},
{name: "UEN (Others, 1800s)", code: "R00LL0001F", err: false},
{name: "NIRC/FIN", code: "S1234567A", err: true},
{name: "Invalid short", code: "1234567A", err: true},
{name: "Invalid UEN (ROC)", code: "2199123456", err: true},
{name: "Invalid UEN (ROB)", code: "1234567A", err: true},
{name: "Invalid UEN (Others)", code: "T12A1234A", err: true},
{name: "Unknown UEN entity type", code: "T12AB1234D", err: true},
{name: "Invalid UEN (ROB) check character", code: "00192200A", err: true},
{name: "Invalid UEN (ROC) check character", code: "197401143A", err: true},
{name: "Invalid UEN (Others) check character", code: "T08GB0020A", err: true},
{name: "Empty code", code: "", err: true},
}

Expand Down
34 changes: 5 additions & 29 deletions regimes/sg/tax_identity.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package sg

import (
"errors"
"regexp"

"github.com/invopop/gobl/cbc"
Expand All @@ -13,15 +12,9 @@ import (
// Reference: https://mytax.iras.gov.sg/ESVWeb/default.aspx?target=GSTListingSearch
// Reference: https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/singapore-tin.pdf
// Reference:https://www.mof.gov.sg/docs/default-source/default-document-library/news-and-publications/press-releases/annexe060808.pdf?sfvrsn=4ee26b50_2
// Singapore’s tax authority does not publish a public checksum algorithm for UEN or GST numbers.
// Indeed, IRAS directs users to verify UENs via the official portal

// regexpsGSTCode uses the UEN identities as a base and adds the GST format used
// for international companies.
var regexpsGSTCode = append(
regexpsUENIdentities,
regexp.MustCompile(`^M[A-Z0-9]\d{7}[A-Z]$`),
)
// IRAS-assigned GST registration numbers have no public checksum algorithm.
// The final character may be either a letter or a digit.
var regexpGSTNumber = regexp.MustCompile(`^M[A-Z0-9]\d{7}[A-Z0-9]$`)

func taxIdentityRules() *rules.Set {
return rules.For(new(tax.Identity),
Expand All @@ -40,23 +33,6 @@ func isValidTaxIdentityCode(value any) bool {
if !ok || code == "" {
return false
}
return validateTaxCode(code) == nil
}

func validateTaxCode(code cbc.Code) error {
if code == "" {
return nil
}
val := code.String()
match := false
for _, re := range regexpsGSTCode {
if re.MatchString(val) {
match = true
break
}
}
if !match {
return errors.New("invalid format")
}
return nil
text := code.String()
return regexpGSTNumber.MatchString(text) || validateUENCode(text)
}
10 changes: 6 additions & 4 deletions regimes/sg/tax_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ func TestTaxIdentityRules(t *testing.T) {
{name: "sole proprietorship GST", code: "MR2345678A"},
{name: "overseas vendor GST", code: "MB2345678A"},
{name: "overseas vendor GST 2", code: "MX2345678A"},
{name: "IRAS-assigned GST with digit suffix", code: "M201189853"},
{name: "invalid GST short", code: "M91234567", err: "IDENTITY-01"},
{name: "invalid GST long", code: "M91234567XA", err: "IDENTITY-01"},
{name: "invalid GST no M", code: "912345678X", err: "IDENTITY-01"},
{name: "invalid GST no end letter", code: "M912345678", err: "IDENTITY-01"},
// UEN identities also
{name: "UEN (ROC)", code: "199912345A"},
{name: "UEN (ROB)", code: "12345678A"},
{name: "UEN (Others)", code: "T12AB1234A"},
{name: "UEN (ROC)", code: "197401143C"},
{name: "UEN (ROB)", code: "00192200M"},
{name: "UEN (Others)", code: "T08GB0020K"},
{name: "NIRC/FIN", code: "S1234567A", err: "IDENTITY-01"},
{name: "Invalid short", code: "1234567A", err: "IDENTITY-01"},
{name: "Invalid UEN (ROC)", code: "2199123456", err: "IDENTITY-01"},
{name: "Invalid UEN (ROB)", code: "1234567A", err: "IDENTITY-01"},
{name: "Invalid UEN (Others)", code: "T12A1234A", err: "IDENTITY-01"},
{name: "Unknown UEN entity type", code: "T12AB1234D", err: "IDENTITY-01"},
{name: "Invalid UEN check character", code: "197401143A", err: "IDENTITY-01"},
}

for _, tt := range tests {
Expand Down
Loading