Saleor: Add API for invoices

Created on 4 Mar 2020  路  4Comments  路  Source: mirumee/saleor

Schema proposal

diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 3614c9e05..3ed5fea19 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -1824,11 +1824,75 @@ type Image {
   alt: String
 }

+type Invoice implements Job & Node & ObjectWithMetadata {
+  id: ID!
+  created: DateTime!
+  number: String!
+  order: Order!
+  url: String
+  privateMetadata: [MetadataItem]!
+  metadata: [MetadataItem]!
+  privateMeta: [MetaStore]! @deprecated(reason: "DEPRECATED: Will be removed in Saleor 2.11. use the `privetaMetadata` field instead. ")
+  meta: [MetaStore]! @deprecated(reason: "DEPRECATED: Will be removed in Saleor 2.11. use the `metadata` field instead. ")
+}
+
+type InvoiceCreate {
+  errors: [Error!]!
+  invoice: Invoice
+  invoiceErrors: [InvoiceError!]!
+  order: Order
+}
+
+type InvoiceDelete {
+  errors: [Error!]!
+  invoiceErrors: [InvoiceError!]!
+  order: Order
+}
+
+type InvoiceError {
+  code: InvoiceErrorCode!
+  field: String
+  message: String
+}
+
+enum InvoiceErrorCode {
+  PLUGIN_NOT_ENABLED
+}
+
+type InvoiceUpdate {
+  errors: [Error!]!
+  invoice: Invoice
+  invoiceErrors: [InvoiceError!]!
+  order: Order
+}
+
+input InvoiceInput {
+  created: DateTime
+  number: String
+  url: String
+}
+
+type InvoiceSend {
+  errors: [Error!]!
+  invoiceErrors: [InvoiceError!]!
+}
+
 input IntRangeInput {
   gte: Int
   lte: Int
 }

+interface Job {
+  status: JobStatus
+}
+
+enum JobStatus {
+  PENDING_CREATION
+  OK
+  PENDING_DELETION
+  DELETED
+}
+
 scalar JSONString

 enum LanguageCodeEnum {
@@ -2401,6 +2465,12 @@ type Mutation {
   permissionGroupDelete(id: ID!): PermissionGroupDelete
   permissionGroupAssignUsers(id: ID!, users: [ID!]!): PermissionGroupAssignUsers
   permissionGroupUnassignUsers(id: ID!, users: [ID!]!): PermissionGroupUnassignUsers
+  invoiceCreate(orderID: ID!, input: InvoiceInput): InvoiceCreate
+  invoiceDelete(orderID: ID!, invoiceID: ID!): InvoiceDelete
+  invoiceUpdate(invoiceID: ID!, input: InvoiceInput): InvoiceUpdate
+  invoiceSend(id: ID!, email: String): InvoiceSend
+  requestInvoiceCreate(orderID: ID!, input: RequestInvoiceCreateInput): RequestInvoiceCreate
+  requestInvoiceDelete(orderID: ID!): RequestInvoiceDelete
 }

 input NameTranslationInput {
@@ -2472,6 +2542,7 @@ type Order implements Node & ObjectWithMetadata {
   totalBalance: Money!
   userEmail: String
   isShippingRequired: Boolean!
+  invoices: [Invoice!]
 }

 enum OrderAction {
@@ -2588,6 +2659,7 @@ type OrderEvent implements Node {
   oversoldItems: [String]
   lines: [OrderEventOrderLineObject]
   fulfilledItems: [FulfillmentLine]
+  invoice: Invoice
 }

 type OrderEventCountableConnection {
@@ -2638,6 +2710,9 @@ enum OrderEventsEnum {
   TRACKING_UPDATED
   NOTE_ADDED
   OTHER
+  INVOICE_CREATED
+  INVOICE_DELETED
+  INVOICE_SENT
 }

 input OrderFilterInput {
@@ -3763,6 +3838,23 @@ type RequestEmailChange {
   accountErrors: [AccountError!]!
 }

+type RequestInvoiceCreate {
+  errors: [Error!]!
+  invoice: Invoice
+  invoiceErrors: [InvoiceError!]!
+  order: Order
+}
+
+input RequestInvoiceCreateInput {
+  number: String
+}
+
+type RequestInvoiceDelete {
+  errors: [Error!]!
+  invoiceErrors: [InvoiceError!]!
+  order: Order
+}
+
 type RequestPasswordReset {
   errors: [Error!]!
   accountErrors: [AccountError!]!
@@ -4982,6 +5074,9 @@ enum WebhookEventTypeEnum {
   PRODUCT_CREATED
   CHECKOUT_QUANTITY_CHANGED
   FULFILLMENT_CREATED
+  INVOICE_CREATED
+  INVOICE_DELETED
+  INVOICE_SENT
 }

 input WebhookFilterInput {
discussion

Most helpful comment

My suggestions is creating plugin for invoicing. Schema and models I propose (and which we are currently using):

class Invoice(models.Model):
    created = models.DateTimeField()
    invoice_type = models.CharField(max_length=32, default=InvoiceType.PRIVATE, choices=InvoiceType.CHOICES)
    invoice_number = models.CharField(max_length=256)
    token = models.UUIDField(default=uuid4, unique=True)
    order = models.OneToOneField(Order, related_name="invoice", unique=True, on_delete=models.PROTECT)
    content = JSONField(default=dict, encoder=DjangoJSONEncoder)

    def __str__(self):
        return f"{self.invoice_number} {self.invoice_type}"

    class Meta:
        unique_together = ('invoice_number', 'invoice_type',)

It's important to have content field (JSONField) which should NEVER be edited, content should contain all information included inside Invoice.
Each update of order information, should result in creating simmilar object to model for example InvoiceCorrection, with FK to original invoice.

In our system we are generating invoices only for fully paid orders, so we are using manager.order_fully_paid in which we:
a) generate CONTENT (json with all needed information)
b) create invoice number, in our case based reseted each month

so it looks something like this:
```
invoice_content = prepare_invoice_data(order=order, seller=self.config.seller_params)
created = timezone.now()
invoice_type = InvoiceType.PRIVATE
if order.billing_address.tax_id:
invoice_type = InvoiceType.COMPANY

    qs = Invoice.objects.filter(created__month=created.month, created__year=created.year).order_by("-pk")
    if self.config.config_params["separate_invoice_number"]:
        qs = qs.filter(invoice_type=invoice_type)

    last_invoice = qs.first()

    if last_invoice is None:
        invoice_number = f"1/{created.month}/{created.year}"
    else:
        invoice_id = int(last_invoice.invoice_number.split("/")[0]) + 1
        invoice_number = f"{invoice_id}/{created.month}/{created.year}"

    Invoice.objects.create(
        order=order,
        created=created,
        invoice_type=invoice_type,
        invoice_number=invoice_number,
        content=invoice_content,
    )

```

about schema, we can just use
resolve_invoice inside Order schema, which returns order.invoice

All 4 comments

My suggestions is creating plugin for invoicing. Schema and models I propose (and which we are currently using):

class Invoice(models.Model):
    created = models.DateTimeField()
    invoice_type = models.CharField(max_length=32, default=InvoiceType.PRIVATE, choices=InvoiceType.CHOICES)
    invoice_number = models.CharField(max_length=256)
    token = models.UUIDField(default=uuid4, unique=True)
    order = models.OneToOneField(Order, related_name="invoice", unique=True, on_delete=models.PROTECT)
    content = JSONField(default=dict, encoder=DjangoJSONEncoder)

    def __str__(self):
        return f"{self.invoice_number} {self.invoice_type}"

    class Meta:
        unique_together = ('invoice_number', 'invoice_type',)

It's important to have content field (JSONField) which should NEVER be edited, content should contain all information included inside Invoice.
Each update of order information, should result in creating simmilar object to model for example InvoiceCorrection, with FK to original invoice.

In our system we are generating invoices only for fully paid orders, so we are using manager.order_fully_paid in which we:
a) generate CONTENT (json with all needed information)
b) create invoice number, in our case based reseted each month

so it looks something like this:
```
invoice_content = prepare_invoice_data(order=order, seller=self.config.seller_params)
created = timezone.now()
invoice_type = InvoiceType.PRIVATE
if order.billing_address.tax_id:
invoice_type = InvoiceType.COMPANY

    qs = Invoice.objects.filter(created__month=created.month, created__year=created.year).order_by("-pk")
    if self.config.config_params["separate_invoice_number"]:
        qs = qs.filter(invoice_type=invoice_type)

    last_invoice = qs.first()

    if last_invoice is None:
        invoice_number = f"1/{created.month}/{created.year}"
    else:
        invoice_id = int(last_invoice.invoice_number.split("/")[0]) + 1
        invoice_number = f"{invoice_id}/{created.month}/{created.year}"

    Invoice.objects.create(
        order=order,
        created=created,
        invoice_type=invoice_type,
        invoice_number=invoice_number,
        content=invoice_content,
    )

```

about schema, we can just use
resolve_invoice inside Order schema, which returns order.invoice

@Boquete Thanks a lot for these tips. We will think, what can be useful in our implementation (cc: @tomaszszymanski129 ). The invoice interface will be implemented in a plugin architecture as you said. We are adding hooks(and webhooks) to Saleor now. The next step will be a preparation of some basic plugin based on the old saleor's invoice implementation. We agreed that the plugin will be responsible for storing, preparing and serving the invoice via URL links - as we think that more advanced plugins could use external Invoice API for preparing the invoice files, so we don't want to limit them. Saleor will expect that the invoice DB object will have the url link to the invoice file.

@Boquete Thanks a lot for these tips. We will think, what can be useful in our implementation (cc: @tomaszszymanski129 ). The invoice interface will be implemented in a plugin architecture as you said. We are adding hooks(and webhooks) to Saleor now. The next step will be a preparation of some basic plugin based on the old saleor's invoice implementation. We agreed that the plugin will be responsible for storing, preparing and serving the invoice via URL links - as we think that more advanced plugins could use external Invoice API for preparing the invoice files, so we don't want to limit them. Saleor will expect that the invoice DB object will have the url link to the invoice file.

@korycins yeah in our case we just used token, which we use to generate Django view as application/pdf (w.usage of weasyprint). it's not perfect solution, as saleor should only be graphql API server, but on our case it's enough.

We'll continue the discussion about the schema in #5353.

Was this page helpful?
0 / 5 - 0 ratings