diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e8d753..17037a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- `sg`: added Singapore regime + ## [v0.208.0] - 2025-01-07 ### Added diff --git a/data/regimes/sg.json b/data/regimes/sg.json new file mode 100644 index 00000000..c3bb7a7e --- /dev/null +++ b/data/regimes/sg.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "en": "Singapore" + }, + "time_zone": "Asia/Singapore", + "country": "SG", + "currency": "SGD", + "tags": [ + { + "schema": "bill/invoice", + "list": [ + { + "key": "simplified", + "name": { + "de": "Vereinfachte Rechnung", + "en": "Simplified Invoice", + "es": "Factura Simplificada", + "it": "Fattura Semplificata" + }, + "desc": { + "de": "Wird für B2C-Transaktionen verwendet, wenn die Kundendaten nicht verfügbar sind. Bitte wenden Sie sich an die örtlichen Behörden, um die Grenzwerte zu ermitteln.", + "en": "Used for B2C transactions when the client details are not available, check with local authorities for limits.", + "es": "Usado para transacciones B2C cuando los detalles del cliente no están disponibles, consulte con las autoridades locales para los límites.", + "it": "Utilizzato per le transazioni B2C quando i dettagli del cliente non sono disponibili, controllare con le autorità locali per i limiti." + } + }, + { + "key": "reverse-charge", + "name": { + "de": "Umkehr der Steuerschuld", + "en": "Reverse Charge", + "es": "Inversión del Sujeto Pasivo", + "it": "Inversione del soggetto passivo" + } + }, + { + "key": "self-billed", + "name": { + "de": "Rechnung durch den Leistungsempfänger", + "en": "Self-billed", + "es": "Facturación por el destinatario", + "it": "Autofattura" + } + }, + { + "key": "customer-rates", + "name": { + "de": "Kundensätze", + "en": "Customer rates", + "es": "Tarifas aplicables al destinatario", + "it": "Aliquote applicabili al destinatario" + } + }, + { + "key": "partial", + "name": { + "de": "Teilweise", + "en": "Partial", + "es": "Parcial", + "it": "Parziale" + } + }, + { + "key": "receipt", + "name": { + "en": "Receipt" + } + } + ] + } + ], + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "tags": [ + "reverse-charge" + ], + "note": { + "key": "legal", + "src": "reverse-charge", + "text": "This supply is subject to reverse charge. GST to be accounted for by the recipient." + } + }, + { + "tags": [ + "simplified" + ], + "note": { + "key": "legal", + "src": "simplified", + "text": "Price Payable includes GST" + } + }, + { + "tags": [ + "receipt" + ], + "note": { + "key": "legal", + "src": "receipt", + "text": "Price Payable includes GST" + } + } + ] + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note" + ] + } + ], + "categories": [ + { + "code": "GST", + "name": { + "en": "GST" + }, + "title": { + "en": "Goods and Services Tax" + }, + "rates": [ + { + "key": "zero", + "name": { + "en": "Zero Rate" + }, + "desc": { + "en": "Zero-rated supplies are goods and services that are taxable at 0%: this referes to international services and export of goods." + }, + "values": [ + { + "percent": "0.0%" + } + ] + }, + { + "key": "standard", + "name": { + "en": "Standard rate" + }, + "desc": { + "en": "For the majority of sales of goods and services: it applies to all products or services for which no other rate is expressly provided." + }, + "values": [ + { + "since": "2024-01-01", + "percent": "9%" + } + ] + }, + { + "key": "exempt", + "name": { + "en": "Exempt" + }, + "desc": { + "en": "Certain goods and services are exempt from GST: this includes financial services, sale and lease of residential properties, digital payment tokens, and the import of investment precious metals." + }, + "exempt": true + } + ], + "sources": [ + { + "title": { + "en": "Goods and Services Tax (GST)" + }, + "url": "https://www.iras.gov.sg/taxes/goods-services-tax-(gst)/" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index 1072a2fb..46e3855f 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -362,6 +362,10 @@ "const": "PT", "title": "Portugal" }, + { + "const": "SG", + "title": "Singapore" + }, { "const": "US", "title": "United States of America" diff --git a/examples/sg/invoice-receipt.json b/examples/sg/invoice-receipt.json new file mode 100644 index 00000000..0be8d7a3 --- /dev/null +++ b/examples/sg/invoice-receipt.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "code": "1234", + "currency": "SGD", + "issue_date": "2024-01-15", + "tax": { + "tags": ["receipt"] + }, + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + } + }, + "lines": [ + { + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "taxes": [ + { + "cat": "GST", + "rate": "standard" + } + ] + } + ] + + } \ No newline at end of file diff --git a/examples/sg/invoice-simple.json b/examples/sg/invoice-simple.json new file mode 100644 index 00000000..2187e856 --- /dev/null +++ b/examples/sg/invoice-simple.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "code": "1234", + "currency": "SGD", + "issue_date": "2024-01-15", + "tax": { + "tags": ["simplified"] + }, + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + }, + "addresses": [ + { + "street": "Example street 123", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "lines": [ + { + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "taxes": [ + { + "cat": "GST", + "rate": "standard" + } + ] + } + ] + + } \ No newline at end of file diff --git a/examples/sg/invoice.json b/examples/sg/invoice.json new file mode 100644 index 00000000..f9bbf8ae --- /dev/null +++ b/examples/sg/invoice.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "code": "1234", + "currency": "SGD", + "issue_date": "2024-01-15", + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + }, + "addresses": [ + { + "street": "Example street 123", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "customer": { + "name": "EXAMPLE CUSTOMER", + "addresses": [ + { + "street": "Example street 321", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "lines": [ + { + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "taxes": [ + { + "cat": "GST", + "rate": "standard" + } + ] + } + ] + + } + \ No newline at end of file diff --git a/examples/sg/out/invoice-receipt.json b/examples/sg/out/invoice-receipt.json new file mode 100644 index 00000000..e5660a8f --- /dev/null +++ b/examples/sg/out/invoice-receipt.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "$tags": [ + "receipt" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "1234", + "issue_date": "2024-01-15", + "currency": "SGD", + "tax": {}, + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + } + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "sum": "200000.00", + "taxes": [ + { + "cat": "GST", + "rate": "standard", + "percent": "9%" + } + ], + "total": "200000.00" + } + ], + "totals": { + "sum": "200000.00", + "total": "200000.00", + "taxes": { + "categories": [ + { + "code": "GST", + "rates": [ + { + "key": "standard", + "base": "200000.00", + "percent": "9%", + "amount": "18000.00" + } + ], + "amount": "18000.00" + } + ], + "sum": "18000.00" + }, + "tax": "18000.00", + "total_with_tax": "218000.00", + "payable": "218000.00" + }, + "notes": [ + { + "key": "legal", + "src": "receipt", + "text": "Price Payable includes GST" + } + ] +} \ No newline at end of file diff --git a/examples/sg/out/invoice-simple.json b/examples/sg/out/invoice-simple.json new file mode 100644 index 00000000..8778a154 --- /dev/null +++ b/examples/sg/out/invoice-simple.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "$tags": [ + "simplified" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "1234", + "issue_date": "2024-01-15", + "currency": "SGD", + "tax": {}, + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + }, + "addresses": [ + { + "street": "Example street 123", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "sum": "200000.00", + "taxes": [ + { + "cat": "GST", + "rate": "standard", + "percent": "9%" + } + ], + "total": "200000.00" + } + ], + "totals": { + "sum": "200000.00", + "total": "200000.00", + "taxes": { + "categories": [ + { + "code": "GST", + "rates": [ + { + "key": "standard", + "base": "200000.00", + "percent": "9%", + "amount": "18000.00" + } + ], + "amount": "18000.00" + } + ], + "sum": "18000.00" + }, + "tax": "18000.00", + "total_with_tax": "218000.00", + "payable": "218000.00" + }, + "notes": [ + { + "key": "legal", + "src": "simplified", + "text": "Price Payable includes GST" + } + ] +} \ No newline at end of file diff --git a/examples/sg/out/invoice.json b/examples/sg/out/invoice.json new file mode 100644 index 00000000..f11a03ab --- /dev/null +++ b/examples/sg/out/invoice.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "SG", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "1234", + "issue_date": "2024-01-15", + "currency": "SGD", + "supplier": { + "name": "EXAMPLE SUPPLIER", + "tax_id": { + "country": "SG", + "code": "201312345A" + }, + "addresses": [ + { + "street": "Example street 123", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "customer": { + "name": "EXAMPLE CUSTOMER", + "addresses": [ + { + "street": "Example street 321", + "locality": "Singapore", + "country": "SG" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Useful service", + "price": "200000.00" + }, + "sum": "200000.00", + "taxes": [ + { + "cat": "GST", + "rate": "standard", + "percent": "9%" + } + ], + "total": "200000.00" + } + ], + "totals": { + "sum": "200000.00", + "total": "200000.00", + "taxes": { + "categories": [ + { + "code": "GST", + "rates": [ + { + "key": "standard", + "base": "200000.00", + "percent": "9%", + "amount": "18000.00" + } + ], + "amount": "18000.00" + } + ], + "sum": "18000.00" + }, + "tax": "18000.00", + "total_with_tax": "218000.00", + "payable": "218000.00" + } +} \ No newline at end of file diff --git a/regimes/regimes.go b/regimes/regimes.go index 358f69eb..5f9e744c 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -23,5 +23,6 @@ import ( _ "github.com/invopop/gobl/regimes/nl" _ "github.com/invopop/gobl/regimes/pl" _ "github.com/invopop/gobl/regimes/pt" + _ "github.com/invopop/gobl/regimes/sg" _ "github.com/invopop/gobl/regimes/us" ) diff --git a/regimes/sg/README.md b/regimes/sg/README.md new file mode 100644 index 00000000..a3f6b7c3 --- /dev/null +++ b/regimes/sg/README.md @@ -0,0 +1,75 @@ +# 🇸🇬 GOBL Singapore Tax Regime + +This document provides an overview of the tax regime in Singapore + +Find example SG GOBL files in the [`examples`](../../examples/sg) (uncalculated documents) and [`examples/out`](../../examples/sg/out) (calculated envelopes) subdirectories. + +--- + +## Overview of GST + +Singapore offers a simple GST model with a standard rate along with a few exceptions. It also offers a few methods for invoicing which will be described further down. Singapore also uses a wide variaty of TIN alternatives as their GST registration number. GST is handled by the Inland Revenue Authority of Singapore ([IRAS](https://www.iras.gov.sg/taxes/goods-services-tax-(gst))) + +--- + +## Rates + +1. Standard rate of **9%**. (Since 01/01/2024) +2. Zero-rate which applies to international services and export of goods. +3. Exempt Supplies which include financial services, sale and lease of residential properties, digital payment tokens, and the import of investment precious metals. + +*Other tax rates such as 50% discount on selling price for second hand goods are not covered yet* + +## Invoicing methods + +There are three main methods for invoicing which will be described below. Other methods like credit notes and reverse billing have to follow the structure of a normal Tax Invoice. + +### Tax Invoice + +This invoice, reference in GOBL by the use of the tax tag "standard" represents a basic Invoice. This Invoice has to meet certain requirements: + +1. The words “tax invoice” in a prominent place. +2. An identifying number (e.g. invoice number). +3. Date of issue of the invoice. +4. Supplier business name, address and GST registration number. +5. Customer’s name and address. +6. A description sufficient to identify the goods or services supplied and the type of supply. +7. For each description of goods or services supplied, the quantity of goods or the extent of services, and the amount payable, excluding GST. +8. Any cash discount offered. +9. The total amount payable (excluding GST), the GST rate and the total amount of GST chargeable. +10. The total amount payable (including the total amount of GSTchargeable). +11. A breakdown of exempt, zero-rated or other supplies, stating separatelythe gross total amount payable in respect of each type of supply. + +### Simplified Tax Invoice + +This invoice is referenced by the tax tag "simplified". This invoice can only be used when the total amount (inclusive of GST) is less than $1000. This invoice has less requirements: + +1. Suplier name, address and GST registration number; +2. An identifying number, e.g. invoice number. +3. The date of issue of the invoice. +4. Description of the goods or services supplied. +5. The total amount payable including tax. +6. The word “Price Payable includes GST”. + +### Reciept + +This type of invoice can be issued to a non-GST registered costumer. This invoice requires the following: + +1. Suplier name, address and GST registration number; +2. The date of issue of the invoice. +3. The total amount payable including tax. +4. The word “Price Payable includes GST”. + +### GST Registration Number + +There are multiple possiblities when it comes to GST reg nums. They can be a Unique Entity Number (UEN) which refers to business, they can be a National Registration Identity Card (NRIC) number or a Foreign Identification Number which refer to people, and they can be a unique GST reg num emmited by IRAS. Each code has its own validation rules along which have been implemented. Each type of code is still a valid GST registration number and must be included in all invoices. + +### References + +[GST General Guide for Businesses](https://www.iras.gov.sg/media/docs/default-source/e-tax/etaxguide_gst_gst-general-guide-for-businesses(1).pdf?sfvrsn=8a66716d_97) + +[GST Rates](https://www.iras.gov.sg/taxes/goods-services-tax-(gst)/basics-of-gst/current-gst-rates) + + + + diff --git a/regimes/sg/invoices.go b/regimes/sg/invoices.go new file mode 100644 index 00000000..a0b863be --- /dev/null +++ b/regimes/sg/invoices.go @@ -0,0 +1,84 @@ +package sg + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Reference: https://www.iras.gov.sg/media/docs/default-source/e-tax/etaxguide_gst_gst-general-guide-for-businesses(1).pdf?sfvrsn=8a66716d_97 (pg 26-27) + +// Invoice type tags +const ( + TagInvoiceReceipt cbc.Key = "receipt" +) + +var invoiceTags = &tax.TagSet{ + Schema: bill.ShortSchemaInvoice, + List: []*cbc.Definition{ + { + Key: TagInvoiceReceipt, + Name: i18n.String{ + i18n.EN: "Receipt", + }, + }, + }, +} + +func validateInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Supplier, + validation.When( + inv.HasTags(TagInvoiceReceipt), + validation.By(validateRecieptSupplier), + validation.Skip, + ).Else( + validation.By(validateInvoiceSupplier), + validation.Skip, + ), + ), + validation.Field(&inv.Customer, + validation.When( + inv.HasTags(TagInvoiceReceipt) || inv.HasTags(tax.TagSimplified), + validation.Skip, + ).Else(validation.Required), + ), + ) +} + +func validateInvoiceSupplier(value any) error { + p, ok := value.(*org.Party) + if !ok || p == nil { + return nil + } + return validation.ValidateStruct(p, + validation.Field(&p.TaxID, + validation.Required, + tax.RequireIdentityCode, + validation.Skip, + ), + validation.Field(&p.Name, + validation.Required, + ), + validation.Field(&p.Addresses, + validation.Required, + ), + ) +} + +func validateRecieptSupplier(value any) error { + p, ok := value.(*org.Party) + if !ok || p == nil { + return nil + } + return validation.ValidateStruct(p, + validation.Field(&p.TaxID, + validation.Required, + tax.RequireIdentityCode, + validation.Skip, + ), + ) +} diff --git a/regimes/sg/invoices_test.go b/regimes/sg/invoices_test.go new file mode 100644 index 00000000..5fbe98ae --- /dev/null +++ b/regimes/sg/invoices_test.go @@ -0,0 +1,107 @@ +package sg_test + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/sg" + "github.com/invopop/gobl/tax" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validInvoice() *bill.Invoice { + return &bill.Invoice{ + Supplier: &org.Party{ + TaxID: &tax.Identity{ + Code: "199912345A", + Country: "SG", + }, + Name: "Test Supplier", + Addresses: []*org.Address{ + { + Street: "Test Street", + Code: "123456", + Country: l10n.SG.ISO(), + }, + }, + }, + Customer: &org.Party{ + Name: "Test Customer", + Addresses: []*org.Address{ + { + Street: "Test Street", + Code: "123456", + Country: l10n.SG.ISO(), + }, + }, + }, + Code: "0001", + Currency: "SGD", + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Test Item", + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + { + Category: tax.CategoryGST, + Rate: tax.RateStandard, + }, + }, + }, + }, + } +} + +func TestValidInvoice(t *testing.T) { + inv := validInvoice() + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) +} + +func TestValidReceiptInvoice(t *testing.T) { + inv := validInvoice() + inv.SetTags(sg.TagInvoiceReceipt) + inv.Customer = nil + inv.Supplier.Addresses = nil + + require.NoError(t, inv.Calculate()) + assert.Len(t, inv.Notes, 1) + assert.Equal(t, inv.Notes[0].Src, sg.TagInvoiceReceipt) + assert.Equal(t, inv.Notes[0].Text, "Price Payable includes GST") + require.NoError(t, inv.Validate()) + +} + +func TestValidSimplifiedInvoice(t *testing.T) { + inv := validInvoice() + inv.SetTags(tax.TagSimplified) + inv.Customer = nil + + require.NoError(t, inv.Calculate()) + assert.Len(t, inv.Notes, 1) + assert.Equal(t, inv.Notes[0].Src, tax.TagSimplified) + assert.Equal(t, inv.Notes[0].Text, "Price Payable includes GST") + require.NoError(t, inv.Validate()) +} + +func TestInvalidInvoice(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID.Code = "1234567A" + require.Error(t, inv.Validate()) + + inv = validInvoice() + inv.Customer = nil + require.Error(t, inv.Validate()) + + inv = validInvoice() + inv.Supplier.Addresses = nil + require.Error(t, inv.Validate()) +} diff --git a/regimes/sg/scenarios.go b/regimes/sg/scenarios.go new file mode 100644 index 00000000..f9c76d89 --- /dev/null +++ b/regimes/sg/scenarios.go @@ -0,0 +1,42 @@ +// Package sg provides tax scenarios specific to Singapore GST regulations. +package sg + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +var invoiceScenarios = &tax.ScenarioSet{ + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // Reverse Charges + { + Tags: []cbc.Key{tax.TagReverseCharge}, + Note: &tax.ScenarioNote{ + Key: org.NoteKeyLegal, + Src: tax.TagReverseCharge, + Text: "This supply is subject to reverse charge. GST to be accounted for by the recipient.", + }, + }, + + // Simplified Tax Invoice or Reciept + { + Tags: []cbc.Key{tax.TagSimplified}, + Note: &tax.ScenarioNote{ + Key: org.NoteKeyLegal, + Src: tax.TagSimplified, + Text: "Price Payable includes GST", + }, + }, + { + Tags: []cbc.Key{TagInvoiceReceipt}, + Note: &tax.ScenarioNote{ + Key: org.NoteKeyLegal, + Src: TagInvoiceReceipt, + Text: "Price Payable includes GST", + }, + }, + }, +} diff --git a/regimes/sg/sg.go b/regimes/sg/sg.go new file mode 100644 index 00000000..011f0fa9 --- /dev/null +++ b/regimes/sg/sg.go @@ -0,0 +1,65 @@ +// Package sg provides the tax region definition for Singapore. +package sg + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/regimes/common" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegimeDef(New()) +} + +// New provides the tax region definition +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: "SG", + Currency: currency.SGD, + Name: i18n.String{ + i18n.EN: "Singapore", + }, + TimeZone: "Asia/Singapore", + Tags: []*tax.TagSet{ + common.InvoiceTags().Merge(invoiceTags), + }, + Scenarios: []*tax.ScenarioSet{ + invoiceScenarios, + }, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + // Singpore only supports credit notes to correct an invoice: + // https://www.iras.gov.sg/taxes/goods-services-tax-(gst)/basics-of-gst/invoicing-price-display-and-record-keeping/invoicing-customers + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + }, + }, + Validator: Validate, + Normalizer: Normalize, + Categories: taxCategories, + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc interface{}) error { + switch obj := doc.(type) { + case *bill.Invoice: + return validateInvoice(obj) + case *tax.Identity: + return validateTaxIdentity(obj) + } + return nil +} + +// Normalize will attempt to clean the object passed to it. +func Normalize(doc any) { + switch obj := doc.(type) { + case *tax.Identity: + tax.NormalizeIdentity(obj) + } +} diff --git a/regimes/sg/tax_categories.go b/regimes/sg/tax_categories.go new file mode 100644 index 00000000..fd3f73fb --- /dev/null +++ b/regimes/sg/tax_categories.go @@ -0,0 +1,72 @@ +package sg + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +var taxCategories = []*tax.CategoryDef{ + // GST + { + Code: tax.CategoryGST, + Name: i18n.String{ + i18n.EN: "GST", + }, + Title: i18n.String{ + i18n.EN: "Goods and Services Tax", + }, + Sources: []*tax.Source{ + { + Title: i18n.String{ + i18n.EN: "Goods and Services Tax (GST)", + }, + URL: "https://www.iras.gov.sg/taxes/goods-services-tax-(gst)/", + }, + }, + Retained: false, + Rates: []*tax.RateDef{ + { + Key: tax.RateZero, + Name: i18n.String{ + i18n.EN: "Zero Rate", + }, + Description: i18n.String{ + i18n.EN: "Zero-rated supplies are goods and services that are taxable at 0%: this referes to international services and export of goods.", + }, + + Values: []*tax.RateValueDef{ + { + Percent: num.MakePercentage(0, 3), + }, + }, + }, + { + Key: tax.RateStandard, + Name: i18n.String{ + i18n.EN: "Standard rate", + }, + Description: i18n.String{ + i18n.EN: "For the majority of sales of goods and services: it applies to all products or services for which no other rate is expressly provided.", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2024, 1, 1), + Percent: num.MakePercentage(9, 2), + }, + }, + }, + { + Key: tax.RateExempt, + Name: i18n.String{ + i18n.EN: "Exempt", + }, + Exempt: true, + Description: i18n.String{ + i18n.EN: "Certain goods and services are exempt from GST: this includes financial services, sale and lease of residential properties, digital payment tokens, and the import of investment precious metals.", + }, + }, + }, + }, +} diff --git a/regimes/sg/tax_identity.go b/regimes/sg/tax_identity.go new file mode 100644 index 00000000..4297b1be --- /dev/null +++ b/regimes/sg/tax_identity.go @@ -0,0 +1,56 @@ +package sg + +import ( + "errors" + "regexp" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Reference: https://lookuptax.com/docs/tax-identification-number/singapore-tax-id-guide#nric-number +// Reference: https://mytax.iras.gov.sg/ESVWeb/default.aspx?target=GSTListingSearch +// All these tax codes are valid options for a GST registration number. + +var ( + taxCodeRegexps = []*regexp.Regexp{ + regexp.MustCompile(`^(19[0-9]{2}|20[0-9]{2})\d{5}[A-Z]$`), // UEN (ROC) + regexp.MustCompile(`^\d{9}[A-Z]$`), // UEN (ROB) + regexp.MustCompile(`^[TS]\d{2}[A-Z]\w{1}\d{4}[A-Z]$`), // UEN (Others) + regexp.MustCompile(`^[STFGM]\d{7}[A-Z]$`), // NIRC/FIN + regexp.MustCompile(`^\w{2}\d{7}[A-Z]$`), // GST + + } +) + +// validateTaxIdentity checks to ensure the NIT code looks okay. +func validateTaxIdentity(tID *tax.Identity) error { + return validation.ValidateStruct(tID, + validation.Field(&tID.Code, + validation.By(validateTaxCode), + validation.Skip, + ), + ) +} + +func validateTaxCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + match := false + for _, re := range taxCodeRegexps { + if re.MatchString(val) { + match = true + break + } + } + if !match { + return errors.New("invalid format") + } + + return nil +} diff --git a/regimes/sg/tax_identity_test.go b/regimes/sg/tax_identity_test.go new file mode 100644 index 00000000..6990a9f0 --- /dev/null +++ b/regimes/sg/tax_identity_test.go @@ -0,0 +1,43 @@ +package sg_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/sg" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxIdentity(t *testing.T) { + tests := []struct { + name string + code string + expected bool + }{ + {name: "UEN (ROC)", code: "199912345A", expected: true}, + {name: "UEN (ROB)", code: "123456789A", expected: true}, + {name: "UEN (Others)", code: "T12AB1234A", expected: true}, + {name: "NIRC/FIN", code: "S1234567A", expected: true}, + {name: "GST", code: "AB1234567A", expected: true}, + {name: "Invalid short", code: "1234567A", expected: false}, + {name: "Invalid long", code: "A123456789", expected: false}, + {name: "Invalid UEN (ROC)", code: "2199123456", expected: false}, + {name: "Invalid UEN (ROB)", code: "12345678A", expected: false}, + {name: "Invalid UEN (Others)", code: "T12A1234A", expected: false}, + {name: "Invalid NIRC/FIN", code: "S123456A", expected: false}, + {name: "Invalid GST", code: "A1234567A", expected: false}, + } + + for _, tt := range tests { + tID := &tax.Identity{ + Code: cbc.Code(tt.code), + } + err := sg.Validate(tID) + if tt.expected { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + } +}