diff --git a/billing.go b/billing.go index 7ac570d..f1fecab 100644 --- a/billing.go +++ b/billing.go @@ -8,6 +8,7 @@ import ( "time" "unicode/utf8" + "github.com/asaskevich/govalidator" "github.com/moonrhythm/validator" ) @@ -40,6 +41,39 @@ type Billing interface { DownloadReceipt(ctx context.Context, m *InvoiceGet) (*InvoiceDownloadResult, error) // UploadTransferSlip requires ownership of the invoice's billing account (enforced via GetInvoice). UploadTransferSlip(ctx context.Context, m *InvoiceUploadSlip) (*InvoiceUploadSlipResult, error) + // ListMembers lists the invited members of a billing account. + // Requires the caller to be the account's owner or an admin member. + ListMembers(ctx context.Context, m *BillingMemberList) (*BillingMemberListResult, error) + // AddMember invites a user to a billing account (or changes an existing + // member's role). Requires the caller to be the account's owner or an admin + // member. The owner cannot be added as a member. + AddMember(ctx context.Context, m *BillingMemberAdd) (*Empty, error) + // RemoveMember removes an invited member from a billing account. + // Requires the caller to be the account's owner or an admin member. + RemoveMember(ctx context.Context, m *BillingMemberRemove) (*Empty, error) +} + +// Billing account roles. The owner is implicit (the billing_accounts.owner +// column) and is never stored as a member row. Invited members hold one of the +// two member roles below. +const ( + // BillingRoleOwner is the account's sole owner: full control, including + // deleting the account and managing members. Reported by Get/List for the + // caller's own access; never a stored member role. + BillingRoleOwner = "owner" + // BillingRoleAdmin is a full co-manager: view + pay invoices, edit the + // account's tax details, and manage members. Cannot delete the account. + BillingRoleAdmin = "admin" + // BillingRoleAccountant can view invoices/receipts, view the usage report, + // and pay (upload a transfer slip). Cannot edit tax details, delete the + // account, or manage members. + BillingRoleAccountant = "accountant" +) + +// IsValidBillingMemberRole reports whether role is a role that can be assigned +// to an invited member (owner is implicit and not assignable). +func IsValidBillingMemberRole(role string) bool { + return role == BillingRoleAdmin || role == BillingRoleAccountant } // Billing account entity types. A billing account is either an Individual @@ -135,6 +169,10 @@ type BillingItem struct { TaxName string `json:"taxName" yaml:"taxName"` TaxAddress string `json:"taxAddress" yaml:"taxAddress"` Active bool `json:"active" yaml:"active"` + // Role is the calling user's effective role on this account + // (owner|admin|accountant), so a client can gate management UI without a + // second lookup. Empty on responses that predate membership. + Role string `json:"role" yaml:"role"` } type BillingUpdate struct { @@ -390,3 +428,75 @@ type InvoiceUploadSlipResult struct { DownloadURL string `json:"downloadUrl" yaml:"downloadUrl"` ExpiresAt time.Time `json:"expiresAt" yaml:"expiresAt"` } + +// BillingMember is an invited (non-owner) user on a billing account. +type BillingMember struct { + Email string `json:"email" yaml:"email"` + // Role is the member's role: "admin" or "accountant". + Role string `json:"role" yaml:"role"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` + // CreatedBy is the email of whoever added the member (attribution). + CreatedBy string `json:"createdBy" yaml:"createdBy"` +} + +type BillingMemberList struct { + ID int64 `json:"id,string" yaml:"id"` // billing account id +} + +func (m *BillingMemberList) Valid() error { + v := validator.New() + + v.Must(m.ID > 0, "id required") + + return WrapValidate(v) +} + +type BillingMemberListResult struct { + // Owner is the account's owner email — the implicit "owner" role, listed + // alongside members so a client can render the full access list. + Owner string `json:"owner" yaml:"owner"` + Items []*BillingMember `json:"items" yaml:"items"` +} + +type BillingMemberAdd struct { + ID int64 `json:"id,string" yaml:"id"` // billing account id + Email string `json:"email" yaml:"email"` + Role string `json:"role" yaml:"role"` // "admin" or "accountant" +} + +func (m *BillingMemberAdd) Valid() error { + // Canonicalize to lower-case: the invitee is matched against the email their + // identity provider hands us (canonical lower-case), so a mixed-case invite + // like "Bob@Example.com" must resolve to the same member row, not lock them out. + m.Email = strings.ToLower(strings.TrimSpace(m.Email)) + m.Role = strings.TrimSpace(m.Role) + + v := validator.New() + + v.Must(m.ID > 0, "id required") + v.Must(m.Email != "", "email required") + // IsEmail also rejects allUsers / allAuthenticatedUsers: billing is money, + // so a member must be a real, addressable identity — no public principals. + v.Must(govalidator.IsEmail(m.Email), "email invalid") + v.Must(IsValidBillingMemberRole(m.Role), "role must be admin or accountant") + + return WrapValidate(v) +} + +type BillingMemberRemove struct { + ID int64 `json:"id,string" yaml:"id"` // billing account id + Email string `json:"email" yaml:"email"` +} + +func (m *BillingMemberRemove) Valid() error { + // Match the canonicalization AddMember applies, so a member added as + // "Bob@Example.com" (stored lower-case) can be removed by any casing. + m.Email = strings.ToLower(strings.TrimSpace(m.Email)) + + v := validator.New() + + v.Must(m.ID > 0, "id required") + v.Must(m.Email != "", "email required") + + return WrapValidate(v) +} diff --git a/client/billing.go b/client/billing.go index 7a96aa2..00da8f4 100644 --- a/client/billing.go +++ b/client/billing.go @@ -124,3 +124,30 @@ func (c billingClient) DownloadReceipt(ctx context.Context, m *api.InvoiceGet) ( func (c billingClient) UploadTransferSlip(_ context.Context, _ *api.InvoiceUploadSlip) (*api.InvoiceUploadSlipResult, error) { return nil, nil } + +func (c billingClient) ListMembers(ctx context.Context, m *api.BillingMemberList) (*api.BillingMemberListResult, error) { + var res api.BillingMemberListResult + err := c.inv.invoke(ctx, "billing.listMembers", m, &res) + if err != nil { + return nil, err + } + return &res, nil +} + +func (c billingClient) AddMember(ctx context.Context, m *api.BillingMemberAdd) (*api.Empty, error) { + var res api.Empty + err := c.inv.invoke(ctx, "billing.addMember", m, &res) + if err != nil { + return nil, err + } + return &res, nil +} + +func (c billingClient) RemoveMember(ctx context.Context, m *api.BillingMemberRemove) (*api.Empty, error) { + var res api.Empty + err := c.inv.invoke(ctx, "billing.removeMember", m, &res) + if err != nil { + return nil, err + } + return &res, nil +} diff --git a/errors.go b/errors.go index e3a5274..48e6b32 100644 --- a/errors.go +++ b/errors.go @@ -64,6 +64,9 @@ var ( ErrInvoicePDFUnavailable = newError("api: invoice pdf export is not available") ErrInvoicePDFFailed = newError("api: could not generate the invoice pdf, please try again") ErrInvoiceNotPaid = newError("api: invoice is not paid") + ErrBillingForbidden = newError("api: you do not have permission to perform this action on the billing account") + ErrBillingMemberNotFound = newError("api: billing account member not found") + ErrBillingMemberIsOwner = newError("api: the billing account owner cannot be added as a member") ErrWAFZoneNotFound = newError("api: waf zone not found") ErrWAFRuleInvalid = newError("api: waf rule invalid") ErrCacheZoneNotFound = newError("api: cache zone not found") diff --git a/project.go b/project.go index abce3a0..fa8baf6 100644 --- a/project.go +++ b/project.go @@ -13,12 +13,14 @@ import ( type Project interface { // Create requires authentication only (no specific permission; the new owner role is granted to the creator). + // The caller must be the owner or an admin of the target billing account (accountants and non-members are refused). Create(ctx context.Context, m *ProjectCreate) (*Empty, error) // Get requires authentication only (no specific permission; scoped to projects the caller is a member of, or all projects for a platform admin). Get(ctx context.Context, m *ProjectGet) (*ProjectItem, error) // List requires authentication only (no specific permission; lists the caller's projects, or all projects for a platform admin). List(ctx context.Context, m *Empty) (*ProjectListResult, error) // Update requires the `*` (owner/wildcard) permission. + // Re-pointing the project at a billing account additionally requires the caller to be the owner or an admin of that account. Update(ctx context.Context, m *ProjectUpdate) (*Empty, error) // Delete requires the `project.delete` permission. Delete(ctx context.Context, m *ProjectDelete) (*Empty, error)