# Synthetic Avouch example for Avouch Studio: subscription billing, from plan to refund. # All names and quotes are invented. It shows a larger file: four contexts, three state machines, # idempotency keys, a dunning decision table, derived properties and known source gaps. formatVersion: 1 contexts: Catalog: Plans, prices and coupons Subscriptions: Customers and their subscriptions Invoicing: Invoices, invoice lines and credit notes Payments: Payment attempts and refunds infrastructureStores: [audit_log, processor_webhooks] permission_rows_not_commands: - name: Export revenue report reason: Back-office report; it reads data and changes nothing cite: { doc: 'billing#permissions', quote: Exporting the revenue report needs report:revenue } - name: Download invoice PDF reason: A rendering of an invoice, not a change to it cite: { doc: 'billing#permissions', quote: Any staff member with invoice:read can download the PDF } knownSourceGaps: - id: G1 rule: R6 kind: effect_unspecified keys: ['CHANGE_PLAN:subscription_coupon'] doc_line: 'billing#plan-change' conflict: The plan change section does not say whether a coupon carries over to the new price proposed: State in billing#plan-change whether the coupon stays, is dropped, or is checked against the new plan - id: G2 rule: R5 kind: mechanism_unspecified keys: [ISSUE_REFUND] doc_line: 'billing#refunds' conflict: The refund section says every refund gets a matching credit note, but not how the Payments context writes it to Invoicing proposed: Name the mechanism in billing#refunds, for example the same batch as the refund - id: G3 rule: R12 kind: condition_unspecified keys: ['CHARGE_INVOICE:amount_due_positive'] doc_line: 'billing#charge' conflict: The charge section relies on the amount due, but the formula in billing#invoices has no rule for credits larger than the payments proposed: Give the amount due a floor of 0 in billing#invoices, or say that a negative amount due becomes customer credit outOfScopeObjectTypes: TaxAuthority: Tax rates and filings come from an external tax service; this file only links to it Ledger: The general ledger belongs to the finance system; billing posts to it but does not model it scope: by: account_id global: - { type: Coupon, cite: { doc: 'billing#coupons', quote: Coupons are issued by the platform and can be redeemed in every account } } cite: { doc: 'billing#accounts', quote: 'Every customer, subscription, invoice and payment belongs to one account by account_id' } dispositions: REJECTED: { class: rejected, cite: { doc: 'billing#results', quote: REJECTED means nothing was written } } APPLIED: { class: applied, cite: { doc: 'billing#results', quote: APPLIED means the change is stored } } CHARGE_SENT: { class: pending, cite: { doc: 'billing#results', quote: CHARGE_SENT means the processor has the charge and its result is still to come } } RETRY_SCHEDULED: { class: pending, cite: { doc: 'billing#results', quote: RETRY_SCHEDULED means another charge is planned } } ESCALATED: { class: pending, cite: { doc: 'billing#results', quote: ESCALATED means the collections team takes over } } MARKED_UNCOLLECTIBLE: { class: applied, cite: { doc: 'billing#results', quote: MARKED_UNCOLLECTIBLE means the invoice is written off } } LATE_RESULT_KEPT: { class: recorded, cite: { doc: 'billing#results', quote: LATE_RESULT_KEPT means a processor result arrived after the invoice closed; it is stored for audit only } } objectTypes: Plan: context: Catalog datasource: plans doc: 'billing#plans' properties: active: class: canonical type: boolean cite: { doc: 'billing#plans', quote: A plan is active until it is retired from the price list } Price: context: Catalog datasource: prices doc: 'billing#prices' properties: active: class: canonical type: boolean cite: { doc: 'billing#prices', quote: An archived price stays on old subscriptions but cannot be chosen again } unit_amount: class: canonical type: integer cite: { doc: 'billing#prices', quote: Amounts are stored in minor units of the price currency } interval: class: canonical type: enum cite: { doc: 'billing#prices', quote: A price bills MONTH or YEAR } addon_codes: class: canonical type: { set: enum } cite: { doc: 'billing#prices', quote: Each price lists the add-on codes it offers } Coupon: context: Catalog datasource: coupons doc: 'billing#coupons' properties: valid_until: class: canonical type: timestamp cite: { doc: 'billing#coupons', quote: A coupon can be redeemed until its valid_until time } percent_off: class: canonical type: integer cite: { doc: 'billing#coupons', quote: The discount is a whole percent of each line } Customer: context: Subscriptions datasource: customers doc: 'billing#customers' properties: status: class: canonical type: enum cite: { doc: 'billing#customers', quote: A customer is ACTIVE or CLOSED } email_status: class: canonical type: enum cite: { doc: 'billing#customers', quote: 'The billing email is VERIFIED, UNVERIFIED or BOUNCED' } default_payment_method: class: canonical type: id cite: { doc: 'billing#customers', quote: A customer may store one default payment method } Subscription: context: Subscriptions datasource: subscriptions doc: 'billing#subscriptions' properties: status: class: canonical type: enum cite: { doc: 'billing#subscriptions', quote: Subscription status changes only through subscription and payment actions } trial_started_at: class: canonical type: timestamp cite: { doc: 'billing#trials', quote: The trial starts when the subscription is created } trial_length: class: policy type: duration cite: { doc: 'billing#trials', quote: The trial length is set per account and copied to the subscription at start } current_period_end: class: canonical type: timestamp cite: { doc: 'billing#subscriptions', quote: The current period ends at current_period_end } addon_codes: class: canonical type: { set: enum } cite: { doc: 'billing#subscriptions', quote: A subscription records the add-ons the customer chose } Invoice: context: Invoicing datasource: invoices doc: 'billing#invoices' properties: status: class: canonical type: enum cite: { doc: 'billing#invoices', quote: 'Invoice status changes only through invoice, payment and cancellation actions' } total: class: canonical type: integer cite: { doc: 'billing#invoices', quote: The total is the sum of the lines after discounts and tax } amount_paid: class: canonical type: integer cite: { doc: 'billing#invoices', quote: Each successful attempt adds to amount_paid } amount_credited: class: canonical type: integer cite: { doc: 'billing#credit-notes', quote: Each credit note adds to amount_credited on its invoice } due_on: class: canonical type: date cite: { doc: 'billing#finalize', quote: The due date is set when the invoice is finalized } billing_timezone: class: canonical type: timezone cite: { doc: 'billing#invoices', quote: Dates on an invoice are read in the billing time zone of the account } attempt_count: class: canonical type: integer cite: { doc: 'billing#dunning', quote: Every charge of the invoice counts as one attempt } InvoiceLine: context: Invoicing datasource: invoice_lines doc: 'billing#invoices' properties: amount: class: canonical type: integer cite: { doc: 'billing#invoices', quote: A line amount is the unit amount times the quantity } kind: class: canonical type: enum cite: { doc: 'billing#invoices', quote: A line is a PERIOD charge or a PRORATION } CreditNote: context: Invoicing datasource: credit_notes doc: 'billing#credit-notes' properties: amount: class: canonical type: integer cite: { doc: 'billing#credit-notes', quote: A credit note lowers what the customer owes on one invoice } reason: class: canonical type: enum cite: { doc: 'billing#credit-notes', quote: 'The reason is DUPLICATE, SERVICE_ISSUE or REFUND' } PaymentAttempt: context: Payments datasource: payment_attempts doc: 'billing#payments' properties: status: class: canonical type: enum cite: { doc: 'billing#payments', quote: An attempt stays PENDING until the processor reports back } amount: class: canonical type: integer cite: { doc: 'billing#charge', quote: An attempt charges the amount due at the time of the charge } decline_code: class: canonical type: enum cite: { doc: 'billing#processor-results', quote: A failed attempt keeps the decline code the processor sent } amount_refunded: class: canonical type: integer cite: { doc: 'billing#refunds', quote: Refunds add to amount_refunded on the attempt they return } non_link_fields: processor_ref: reason: Reference in the processor's system; no object of this file cite: { doc: 'billing#payments', quote: processor_ref is the id the processor gave the charge } Refund: context: Payments datasource: refunds doc: 'billing#refunds' properties: amount: class: canonical type: integer cite: { doc: 'billing#refunds', quote: A refund returns part or all of one successful attempt } status: class: derived type: enum cite: { doc: 'billing#refunds', quote: Refund status is copied from the processor report } canonical_values: values: [SUCCEEDED] cite: { doc: 'billing#refunds', quote: A SUCCEEDED refund is final and is never changed back } stateMachines: Subscription: doc: 'billing#subscription-states' initial: TRIALING terminal: [CANCELED] transitions: - { from: TRIALING, to: ACTIVE, by: [ACTIVATE_SUBSCRIPTION] } - { from: TRIALING, to: CANCELED, by: [CANCEL_SUBSCRIPTION] } - { from: ACTIVE, to: PAUSED, by: [PAUSE_SUBSCRIPTION] } - { from: PAUSED, to: ACTIVE, by: [RESUME_SUBSCRIPTION] } - { from: ACTIVE, to: PAST_DUE, by: [RECORD_PAYMENT_RESULT] } - { from: PAST_DUE, to: ACTIVE, by: [RECORD_PAYMENT_RESULT] } - { from: ACTIVE, to: CANCELED, by: [CANCEL_SUBSCRIPTION] } - { from: PAST_DUE, to: CANCELED, by: [CANCEL_SUBSCRIPTION, RETRY_PAYMENT] } - { from: PAUSED, to: CANCELED, by: [], out_of_scope: A pause longer than 90 days is closed by hand at the monthly account review } Invoice: doc: 'billing#invoice-states' initial: DRAFT terminal: [PAID, VOID] transitions: - { from: DRAFT, to: OPEN, by: [FINALIZE_INVOICE] } - { from: DRAFT, to: VOID, by: [VOID_INVOICE, CANCEL_SUBSCRIPTION] } - { from: OPEN, to: VOID, by: [VOID_INVOICE] } - { from: OPEN, to: PAID, by: [RECORD_PAYMENT_RESULT] } - { from: OPEN, to: UNCOLLECTIBLE, by: [RETRY_PAYMENT] } - { from: UNCOLLECTIBLE, to: PAID, by: [], out_of_scope: Money received after a write-off is booked by finance in the ledger } PaymentAttempt: doc: 'billing#attempt-states' initial: PENDING terminal: [SUCCEEDED, FAILED] transitions: - { from: PENDING, to: SUCCEEDED, by: [RECORD_PAYMENT_RESULT] } - { from: PENDING, to: FAILED, by: [RECORD_PAYMENT_RESULT] } linkTypes: - { id: price_plan, from: Price, to: Plan, cardinality: 'N:1', via: plan_id, doc: 'billing#prices' } - { id: subscription_customer, from: Subscription, to: Customer, cardinality: 'N:1', via: customer_id, doc: 'billing#subscriptions' } - { id: subscription_price, from: Subscription, to: Price, cardinality: 'N:1', via: price_id, doc: 'billing#subscriptions' } - { id: subscription_coupon, from: Subscription, to: Coupon, cardinality: 'N:1', via: coupon_id, doc: 'billing#coupons' } - { id: invoice_subscription, from: Invoice, to: Subscription, cardinality: 'N:1', via: subscription_id, doc: 'billing#invoices' } - { id: invoice_customer, from: Invoice, to: Customer, cardinality: 'N:1', via: customer_id, doc: 'billing#invoices' } - { id: line_invoice, from: InvoiceLine, to: Invoice, cardinality: 'N:1', via: invoice_id, doc: 'billing#invoices' } - { id: line_price, from: InvoiceLine, to: Price, cardinality: 'N:1', via: price_id, doc: 'billing#invoices' } - { id: credit_note_invoice, from: CreditNote, to: Invoice, cardinality: 'N:1', via: invoice_id, doc: 'billing#credit-notes' } - { id: attempt_invoice, from: PaymentAttempt, to: Invoice, cardinality: 'N:1', via: invoice_id, doc: 'billing#payments' } - { id: refund_attempt, from: Refund, to: PaymentAttempt, cardinality: 'N:1', via: attempt_id, doc: 'billing#refunds' } - { id: refund_credit_note, from: Refund, to: CreditNote, cardinality: '1:1', via: credit_note_id, doc: 'billing#refunds' } derivedProperties: amount_due: of: Invoice doc: 'billing#invoices' doc_term: Amount due is the total minus amount_paid minus amount_credited reads: [Invoice.total, Invoice.amount_paid, Invoice.amount_credited] trial_over: of: Subscription doc: 'billing#trials' doc_term: Trial over type: boolean expr: { gte: [{ ref: now }, { plus: [{ ref: self.trial_started_at }, { ref: self.trial_length }] }] } cite: { doc: 'billing#trials', quote: The trial is over once the trial length has passed since the trial started } invoice_past_due: of: Invoice doc: 'billing#dunning' doc_term: Past due type: boolean expr: and: - { eq: [{ ref: self.status }, { lit: OPEN }] } - { gt: [{ dateIn: [{ ref: now }, { ref: self.billing_timezone }] }, { ref: self.due_on }] } cite: { doc: 'billing#dunning', quote: An OPEN invoice is past due from the day after its due date in the billing time zone } actionTypes: CREATE_PLAN: context: Catalog doc: 'billing#plans' doc_term: Create plan permission: keys: ['catalog:write'] cite: { doc: 'billing#permissions', quote: Creating a plan and its first price needs catalog:write } parameters: interval: { type: enum, cite: { doc: 'billing#plans', quote: The plan is created with one price and its billing interval } } unit_amount: { type: integer, cite: { doc: 'billing#plans', quote: The first price has a unit_amount in minor units } } conditions: [] creates: [Plan, Price] edits: [] link_effects: price_plan: effect: The first price points to the new plan cite: { doc: 'billing#plans', quote: Every plan starts with exactly one price } subscription_price: { none: A new price has no subscriptions yet } line_price: { none: A new price has no invoice lines yet } emits: [PlanCreated] ARCHIVE_PRICE: context: Catalog doc: 'billing#prices' doc_term: Archive price permission: keys: ['catalog:write'] cite: { doc: 'billing#permissions', quote: Archiving a price needs catalog:write } parameters: price_id: { type: { ref: Price }, cite: { doc: 'billing#prices', quote: The catalog manager picks the price_id to archive } } conditions: - id: price_active expr: { ref: price_id.active } cites: [{ cite: { doc: 'billing#prices', quote: Only an active price can be archived } }] decision: hitPolicy: first rows: - when: { fails: price_active } result: REJECTED cites: [{ cite: { doc: 'billing#prices', quote: Archiving a price that is already archived is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#prices', quote: Otherwise the price is archived and the change is APPLIED } }] } edits: [Price.active] edit_cites: Price.active: { cite: { doc: 'billing#prices', quote: Archiving sets active to false on the price } } link_effects: price_plan: { none: The price stays under its plan } subscription_price: { none: Subscriptions on the price keep it } line_price: { none: Old invoice lines keep the price } emits: [PriceArchived] CREATE_COUPON: context: Catalog doc: 'billing#coupons' doc_term: Create coupon permission: any_of: - keys: ['catalog:write'] cite: { doc: 'billing#permissions', quote: The catalog team creates coupons with catalog:write } - keys: ['marketing:coupons'] cite: { doc: 'billing#permissions', quote: The marketing team creates coupons with marketing:coupons } parameters: percent_off: { type: integer, cite: { doc: 'billing#coupons', quote: A coupon gives a percent_off on each line } } valid_until: { type: timestamp, cite: { doc: 'billing#coupons', quote: The marketing team sets valid_until when the coupon is created } } conditions: [] creates: [Coupon] edits: [] link_effects: subscription_coupon: { none: A new coupon is not yet redeemed } emits: [] START_SUBSCRIPTION: context: Subscriptions doc: 'billing#subscriptions' doc_term: Start subscription permission: keys: ['subscription:create'] cite: { doc: 'billing#permissions', quote: Starting a subscription needs subscription:create } principals: { kinds: [staff, customer], cite: { doc: 'billing#permissions', quote: Staff and customers in the portal can start a subscription } } parameters: customer_id: { type: { ref: Customer }, cite: { doc: 'billing#subscriptions', quote: The subscription is started for customer_id } } price_id: { type: { ref: Price }, cite: { doc: 'billing#subscriptions', quote: The customer chooses one price_id } } addon_codes: { type: { set: enum }, optional: true, cite: { doc: 'billing#subscriptions', quote: The customer may add addon_codes from the price } } coupon_id: { type: { ref: Coupon }, optional: true, cite: { doc: 'billing#coupons', quote: A coupon_id can be given at start } } conditions: - id: customer_active expr: { eq: [{ ref: customer_id.status }, { lit: ACTIVE }] } cites: [{ cite: { doc: 'billing#subscriptions', quote: Only an ACTIVE customer can start a subscription } }] scenarios: [BIL-01.S1] - id: price_active expr: { ref: price_id.active } cites: [{ cite: { doc: 'billing#subscriptions', quote: An archived price cannot be chosen } }] - id: addons_offered expr: or: - { isNull: [{ ref: addon_codes }] } - { subsetOf: [{ ref: addon_codes }, { ref: price_id.addon_codes }] } cites: [{ cite: { doc: 'billing#subscriptions', quote: Add-ons are optional; every chosen add-on must be offered by the price } }] scenarios: [BIL-01.S2] - id: coupon_valid expr: or: - { isNull: [{ ref: coupon_id }] } - { gt: [{ ref: coupon_id.valid_until }, { ref: now }] } cites: [{ cite: { doc: 'billing#coupons', quote: An expired coupon cannot be redeemed } }] decision: hitPolicy: first rows: - when: { fails: customer_active } result: REJECTED cites: [{ cite: { doc: 'billing#subscriptions', quote: A start for a closed customer is REJECTED } }] - when: { fails: price_active } result: REJECTED cites: [{ cite: { doc: 'billing#subscriptions', quote: A start on an archived price is REJECTED } }] - when: { fails: addons_offered } result: REJECTED cites: [{ cite: { doc: 'billing#subscriptions', quote: A start with an add-on the price does not offer is REJECTED } }] - when: { fails: coupon_valid } result: REJECTED cites: [{ cite: { doc: 'billing#coupons', quote: A start with an expired coupon is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#subscriptions', quote: Otherwise the subscription is created in TRIALING and the start is APPLIED } }] } creates: [Subscription] edits: [] link_effects: subscription_customer: effect: The new subscription points to the customer cite: { doc: 'billing#subscriptions', quote: Each subscription belongs to one customer } scenarios: [BIL-01.S1] subscription_price: effect: The new subscription points to the chosen price cite: { doc: 'billing#subscriptions', quote: Each subscription bills one price at a time } subscription_coupon: effect: The new subscription points to the coupon, when one is given cite: { doc: 'billing#coupons', quote: A redeemed coupon stays on the subscription } invoice_subscription: { none: The first invoice is created at the end of the trial } emits: [SubscriptionStarted] evidence: startSubscription ACTIVATE_SUBSCRIPTION: context: Subscriptions doc: 'billing#trials' doc_term: Activate subscription permission: keys: ['subscription:activate'] cite: { doc: 'billing#permissions', quote: Ending a trial needs subscription:activate } principals: { kinds: [service, customer], cite: { doc: 'billing#permissions', quote: The scheduler or the customer ends a trial } } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#trials', quote: The trial of subscription_id ends } } convert_early: { type: boolean, optional: true, cite: { doc: 'billing#trials', quote: The customer can convert_early before the trial is over } } conditions: - id: subscription_trialing expr: { eq: [{ ref: subscription_id.status }, { lit: TRIALING }] } cites: [{ cite: { doc: 'billing#trials', quote: Only a TRIALING subscription can be activated } }] - id: trial_done_or_early expr: or: - { derived: { id: trial_over, of: { ref: subscription_id } } } - { ref: convert_early } cites: [{ cite: { doc: 'billing#trials', quote: 'A trial converts when it is over, or earlier when the customer asks' } }] scenarios: [BIL-02.S1] - id: payment_method_on_file expr: exists: as: c in: { nav: { from: { ref: subscription_id }, link: subscription_customer, dir: forward } } where: { isNotNull: [{ ref: c.default_payment_method }] } cites: [{ cite: { doc: 'billing#trials', quote: A trial converts only when the customer has a default payment method } }] decision: hitPolicy: first order: { cite: { doc: 'billing#trials', quote: The trial checks run in the order listed; the first failure decides } } rows: - when: { fails: subscription_trialing } result: REJECTED cites: [{ cite: { doc: 'billing#trials', quote: Activating a subscription that is not trialing is REJECTED } }] - when: { fails: trial_done_or_early } result: REJECTED cites: [{ cite: { doc: 'billing#trials', quote: Activating a running trial without a request is REJECTED } }] - when: { fails: payment_method_on_file } result: ESCALATED cites: [{ cite: { doc: 'billing#trials', quote: A trial without a payment method is ESCALATED to the customer by email } }] next: { external: The customer adds a payment method in the portal, cite: { doc: 'billing#trials', quote: The email asks the customer to add a card in the portal } } otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#trials', quote: Otherwise the subscription becomes ACTIVE and the change is APPLIED } }] } edits: [Subscription.status] link_effects: subscription_customer: { none: Activation keeps the customer } subscription_price: { none: Activation keeps the price } subscription_coupon: { none: Activation keeps the coupon } invoice_subscription: { none: The scheduler creates the first invoice in a separate action } emits: [SubscriptionActivated] CHANGE_PLAN: context: Subscriptions doc: 'billing#plan-change' doc_term: Change plan permission: keys: ['subscription:manage'] cite: { doc: 'billing#permissions', quote: Changing the plan needs subscription:manage } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#plan-change', quote: The plan of subscription_id changes } } new_price_id: { type: { ref: Price }, cite: { doc: 'billing#plan-change', quote: The customer picks a new_price_id } } addon_codes: { type: { set: enum }, optional: true, cite: { doc: 'billing#plan-change', quote: The customer may change addon_codes at the same time } } conditions: - id: subscription_changeable expr: { in: [{ ref: subscription_id.status }, { lit: [TRIALING, ACTIVE] }] } cites: [{ cite: { doc: 'billing#plan-change', quote: Only a TRIALING or ACTIVE subscription can change plan } }] - id: new_price_active expr: { ref: new_price_id.active } cites: [{ cite: { doc: 'billing#plan-change', quote: The new price must be active } }] - id: addons_offered expr: or: - and: - { isNull: [{ ref: addon_codes }] } - { subsetOf: [{ ref: subscription_id.addon_codes }, { ref: new_price_id.addon_codes }] } - { subsetOf: [{ ref: addon_codes }, { ref: new_price_id.addon_codes }] } cites: [{ cite: { doc: 'billing#plan-change', quote: If the add-on list is left out the current add-ons are kept; every add-on that is given or kept must be offered by the new price } }] decision: hitPolicy: first rows: - when: { fails: subscription_changeable } result: REJECTED cites: [{ cite: { doc: 'billing#plan-change', quote: 'A plan change on a paused, past due or canceled subscription is REJECTED' } }] - when: { fails: new_price_active } result: REJECTED cites: [{ cite: { doc: 'billing#plan-change', quote: A change to an archived price is REJECTED } }] - when: { fails: addons_offered } result: REJECTED cites: [{ cite: { doc: 'billing#plan-change', quote: A change that keeps an add-on the new price lacks is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#plan-change', quote: Otherwise the new price takes effect at once and the change is APPLIED } }] } creates: [InvoiceLine] edits: [Subscription.addon_codes] edit_cites: Subscription.addon_codes: { cite: { doc: 'billing#plan-change', quote: 'The add-on list is replaced by the one given with the change, or kept when none is given' } } crossContext: via: same_batch cite: { doc: 'billing#plan-change', quote: The proration line is added to the draft invoice in the same batch as the change } link_effects: subscription_price: effect: The subscription points to the new price cite: { doc: 'billing#plan-change', quote: The new price replaces the old one on the subscription } subscription_customer: { none: A plan change keeps the customer } subscription_coupon: unspecified: The plan change section does not say what happens to the coupon cite: { doc: 'billing#plan-change', quote: Discounts on the subscription are recalculated } invoice_subscription: { none: The proration line goes on the existing draft invoice } line_invoice: effect: The proration line points to the draft invoice of the period cite: { doc: 'billing#plan-change', quote: The proration line is added to the draft invoice of the current period } line_price: effect: The proration line points to the new price cite: { doc: 'billing#plan-change', quote: The proration line charges the difference at the new price } emits: [PlanChanged] PAUSE_SUBSCRIPTION: context: Subscriptions doc: 'billing#pausing' doc_term: Pause subscription permission: any_of: - keys: ['subscription:manage'] cite: { doc: 'billing#permissions', quote: Support staff pause a subscription with subscription:manage } - keys: ['portal:self-service'] cite: { doc: 'billing#permissions', quote: A customer pauses in the portal with portal:self-service } principals: { kinds: [customer], cite: { doc: 'billing#permissions', quote: Only the customer of the subscription uses the portal key } } idempotencyKey: required: false cite: { doc: 'billing#pausing', quote: 'Pausing twice has the same effect as pausing once, so a pause request needs no idempotency key' } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#pausing', quote: The customer pauses subscription_id } } conditions: - id: subscription_active expr: { eq: [{ ref: subscription_id.status }, { lit: ACTIVE }] } cites: [{ cite: { doc: 'billing#pausing', quote: Only an ACTIVE subscription can be paused } }] - id: nothing_past_due expr: none: as: i in: { nav: { from: { ref: subscription_id }, link: invoice_subscription, dir: reverse } } where: and: - { isNotNull: [{ ref: i.status }] } - { isNotNull: [{ ref: i.due_on }] } - { isNotNull: [{ ref: i.billing_timezone }] } - { derived: { id: invoice_past_due, of: { ref: i } } } cites: [{ cite: { doc: 'billing#pausing', quote: A subscription with a past due invoice cannot be paused } }] scenarios: [BIL-03.S1] decision: hitPolicy: first rows: - when: { fails: subscription_active } result: REJECTED cites: [{ cite: { doc: 'billing#pausing', quote: Pausing a subscription that is not active is REJECTED } }] - when: { fails: nothing_past_due } result: REJECTED cites: [{ cite: { doc: 'billing#pausing', quote: A pause while an invoice is past due is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#pausing', quote: Otherwise billing stops at the end of the period and the pause is APPLIED } }] } edits: [Subscription.status] link_effects: subscription_customer: { none: A pause keeps the customer } subscription_price: { none: A pause keeps the price } subscription_coupon: { none: A pause keeps the coupon } invoice_subscription: { none: A pause does not touch invoices } emits: [SubscriptionPaused] RESUME_SUBSCRIPTION: context: Subscriptions doc: 'billing#pausing' doc_term: Resume subscription permission: keys: ['subscription:manage'] cite: { doc: 'billing#permissions', quote: Resuming needs subscription:manage } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#pausing', quote: Staff resume subscription_id } } conditions: - id: subscription_paused expr: { eq: [{ ref: subscription_id.status }, { lit: PAUSED }] } cites: [{ cite: { doc: 'billing#pausing', quote: Only a PAUSED subscription can be resumed } }] decision: hitPolicy: first rows: - when: { fails: subscription_paused } result: REJECTED cites: [{ cite: { doc: 'billing#pausing', quote: Resuming a subscription that is not paused is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#pausing', quote: Otherwise billing restarts with a new period and the resume is APPLIED } }] } edits: [Subscription.status, Subscription.current_period_end] link_effects: subscription_customer: { none: Resuming keeps the customer } subscription_price: { none: Resuming keeps the price } subscription_coupon: { none: Resuming keeps the coupon } invoice_subscription: { none: The next invoice is created by the scheduler } emits: [SubscriptionResumed] CANCEL_SUBSCRIPTION: context: Subscriptions doc: 'billing#cancel' doc_term: Cancel subscription permission: keys: ['subscription:cancel'] cite: { doc: 'billing#permissions', quote: Cancelling needs subscription:cancel } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#cancel', quote: The customer or staff cancel subscription_id } } conditions: - id: subscription_open expr: { in: [{ ref: subscription_id.status }, { lit: [TRIALING, ACTIVE, PAST_DUE] }] } cites: [{ cite: { doc: 'billing#cancel', quote: 'A TRIALING, ACTIVE or PAST_DUE subscription can be canceled' } }] decision: hitPolicy: first rows: - when: { fails: subscription_open } result: REJECTED cites: [{ cite: { doc: 'billing#cancel', quote: Cancelling a paused or canceled subscription is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#cancel', quote: Otherwise the subscription ends now and the cancellation is APPLIED } }] } edits: [Subscription.status, Invoice.status] edit_cites: Invoice.status: { cite: { doc: 'billing#cancel', quote: Cancelling voids the DRAFT invoice of the current period } } crossContext: via: same_batch cite: { doc: 'billing#cancel', quote: The draft invoice is voided in the same batch as the cancellation } link_effects: subscription_customer: { none: Cancelling keeps the customer } subscription_price: { none: Cancelling keeps the last price } subscription_coupon: { none: Cancelling keeps the coupon for the record } invoice_subscription: { none: The voided invoice still points to the subscription } invoice_customer: { none: Cancelling does not move invoices } line_invoice: { none: Lines stay on the voided invoice } attempt_invoice: { none: A draft invoice has no attempts } credit_note_invoice: { none: A draft invoice has no credit notes } emits: [SubscriptionCanceled] CREATE_INVOICE: context: Invoicing doc: 'billing#invoices' doc_term: Create invoice permission: keys: ['invoice:create'] cite: { doc: 'billing#permissions', quote: Creating a period invoice needs invoice:create } principals: { kinds: [service], cite: { doc: 'billing#permissions', quote: Only the billing scheduler creates period invoices } } parameters: subscription_id: { type: { ref: Subscription }, cite: { doc: 'billing#invoices', quote: The scheduler bills subscription_id at the end of each period } } conditions: - id: subscription_billable expr: { in: [{ ref: subscription_id.status }, { lit: [ACTIVE, PAST_DUE] }] } cites: [{ cite: { doc: 'billing#invoices', quote: An ACTIVE or PAST_DUE subscription is billed every period } }] - id: period_ended expr: { lte: [{ ref: subscription_id.current_period_end }, { ref: now }] } cites: [{ cite: { doc: 'billing#invoices', quote: A period is billed once it has ended } }] - id: no_open_draft expr: none: as: i in: { nav: { from: { ref: subscription_id }, link: invoice_subscription, dir: reverse } } where: { and: [{ isNotNull: [{ ref: i.status }] }, { eq: [{ ref: i.status }, { lit: DRAFT }] }] } cites: [{ cite: { doc: 'billing#invoices', quote: A subscription has at most one DRAFT invoice } }] decision: hitPolicy: first rows: - when: { fails: subscription_billable } result: REJECTED cites: [{ cite: { doc: 'billing#invoices', quote: A paused or canceled subscription is not billed; the run is REJECTED } }] - when: { fails: period_ended } result: REJECTED cites: [{ cite: { doc: 'billing#invoices', quote: Billing a period that has not ended is REJECTED } }] - when: { fails: no_open_draft } result: REJECTED cites: [{ cite: { doc: 'billing#invoices', quote: A second draft for the same subscription is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#invoices', quote: Otherwise a DRAFT invoice with one period line is created and the run is APPLIED } }] } creates: [Invoice, InvoiceLine] edits: [] link_effects: invoice_subscription: effect: The new invoice points to the billed subscription cite: { doc: 'billing#invoices', quote: Each invoice bills one subscription } invoice_customer: effect: The new invoice points to the customer of the subscription cite: { doc: 'billing#invoices', quote: The invoice is addressed to the customer of the subscription } line_invoice: effect: The period line points to the new invoice cite: { doc: 'billing#invoices', quote: The first line is the PERIOD charge } line_price: effect: The period line points to the current price of the subscription cite: { doc: 'billing#invoices', quote: The PERIOD charge uses the current price } attempt_invoice: { none: A draft invoice has no attempts } credit_note_invoice: { none: A draft invoice has no credit notes } emits: [InvoiceCreated] FINALIZE_INVOICE: context: Invoicing doc: 'billing#finalize' doc_term: Finalize invoice permission: keys: ['invoice:finalize'] cite: { doc: 'billing#permissions', quote: Finalizing an invoice needs invoice:finalize } parameters: invoice_id: { type: { ref: Invoice }, cite: { doc: 'billing#finalize', quote: The scheduler finalizes invoice_id one hour after it is created } } conditions: - id: invoice_draft expr: { eq: [{ ref: invoice_id.status }, { lit: DRAFT }] } cites: [{ cite: { doc: 'billing#finalize', quote: Only a DRAFT invoice can be finalized } }] - id: has_lines expr: exists: as: l in: { nav: { from: { ref: invoice_id }, link: line_invoice, dir: reverse } } cites: [{ cite: { doc: 'billing#finalize', quote: An invoice without lines is not finalized } }] - id: total_not_negative expr: { gte: [{ ref: invoice_id.total }, { lit: 0 }] } cites: [{ cite: { doc: 'billing#finalize', quote: A total below 0 is never finalized; it becomes customer credit } }] decision: hitPolicy: first rows: - when: { fails: invoice_draft } result: REJECTED cites: [{ cite: { doc: 'billing#finalize', quote: Finalizing an invoice that is not a draft is REJECTED } }] - when: { fails: has_lines } result: REJECTED cites: [{ cite: { doc: 'billing#finalize', quote: Finalizing an empty invoice is REJECTED } }] - when: { fails: total_not_negative } result: REJECTED cites: [{ cite: { doc: 'billing#finalize', quote: Finalizing a negative total is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#finalize', quote: Otherwise the invoice becomes OPEN with a due date and finalizing is APPLIED } }] } edits: [Invoice.status, Invoice.due_on] link_effects: invoice_subscription: { none: Finalizing keeps the subscription } invoice_customer: { none: Finalizing keeps the customer } line_invoice: { none: 'Lines are frozen, not moved' } attempt_invoice: { none: Finalizing does not charge } credit_note_invoice: { none: Finalizing does not credit } emits: [InvoiceFinalized] VOID_INVOICE: context: Invoicing doc: 'billing#void' doc_term: Void invoice permission: keys: ['invoice:void'] conditional_keys: ['invoice:void:open'] cite: { doc: 'billing#permissions', quote: Voiding a draft needs invoice:void } conditional_cite: { doc: 'billing#permissions', quote: Voiding an OPEN invoice also needs invoice:void:open } parameters: invoice_id: { type: { ref: Invoice }, cite: { doc: 'billing#void', quote: Finance voids invoice_id } } conditions: - id: invoice_voidable expr: { in: [{ ref: invoice_id.status }, { lit: [DRAFT, OPEN] }] } cites: [{ cite: { doc: 'billing#void', quote: A DRAFT or OPEN invoice can be voided } }] - id: nothing_collected expr: not: - exists: as: a in: { nav: { from: { ref: invoice_id }, link: attempt_invoice, dir: reverse } } where: { and: [{ isNotNull: [{ ref: a.status }] }, { eq: [{ ref: a.status }, { lit: SUCCEEDED }] }] } cites: [{ cite: { doc: 'billing#void', quote: 'An invoice with a SUCCEEDED attempt is refunded, not voided' } }] - id: not_yet_due expr: or: - { eq: [{ ref: invoice_id.status }, { lit: DRAFT }] } - { lte: [{ dateIn: [{ ref: now }, { ref: invoice_id.billing_timezone }] }, { ref: invoice_id.due_on }] } cites: [{ cite: { doc: 'billing#void', quote: A DRAFT can be voided at any time; an open invoice only up to its due date } }] decision: hitPolicy: first rows: - when: { fails: invoice_voidable } result: REJECTED cites: [{ cite: { doc: 'billing#void', quote: 'Voiding a paid, void or uncollectible invoice is REJECTED' } }] - when: { fails: nothing_collected } result: REJECTED cites: [{ cite: { doc: 'billing#void', quote: Voiding an invoice with money collected is REJECTED } }] - when: { fails: not_yet_due } result: REJECTED cites: [{ cite: { doc: 'billing#void', quote: Voiding after the due date is REJECTED; dunning decides instead } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#void', quote: Otherwise the invoice becomes VOID and the change is APPLIED } }] } edits: [Invoice.status] link_effects: invoice_subscription: { none: Voiding keeps the subscription } invoice_customer: { none: Voiding keeps the customer } line_invoice: { none: Lines stay on the voided invoice } attempt_invoice: { none: Voiding does not touch attempts } credit_note_invoice: { none: Voiding does not issue credit notes } emits: [InvoiceVoided] ISSUE_CREDIT_NOTE: context: Invoicing doc: 'billing#credit-notes' doc_term: Issue credit note permission: keys: ['invoice:credit'] cite: { doc: 'billing#permissions', quote: Issuing a credit note needs invoice:credit } parameters: invoice_id: { type: { ref: Invoice }, cite: { doc: 'billing#credit-notes', quote: The credit note is issued on invoice_id } } amount: { type: integer, cite: { doc: 'billing#credit-notes', quote: Finance enters the amount to credit } } reason: { type: enum, cite: { doc: 'billing#credit-notes', quote: Finance picks a reason from the list } } conditions: - id: invoice_creditable expr: { in: [{ ref: invoice_id.status }, { lit: [OPEN, PAID] }] } cites: [{ cite: { doc: 'billing#credit-notes', quote: Only an OPEN or PAID invoice can be credited } }] - id: amount_positive expr: { gt: [{ ref: amount }, { lit: 0 }] } cites: [{ cite: { doc: 'billing#credit-notes', quote: The amount must be greater than 0 } }] - id: amount_within_total expr: { lte: [{ ref: amount }, { ref: invoice_id.total }] } cites: [{ cite: { doc: 'billing#credit-notes', quote: One credit note may not exceed the invoice total } }] decision: hitPolicy: first rows: - when: { fails: invoice_creditable } result: REJECTED cites: [{ cite: { doc: 'billing#credit-notes', quote: Crediting a draft or void invoice is REJECTED } }] - when: { fails: amount_positive } result: REJECTED cites: [{ cite: { doc: 'billing#credit-notes', quote: A credit of zero or less is REJECTED } }] - when: { fails: amount_within_total } result: REJECTED cites: [{ cite: { doc: 'billing#credit-notes', quote: A credit above the total is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#credit-notes', quote: Otherwise the credit note is created and the credit is APPLIED } }] } creates: [CreditNote] edits: [Invoice.amount_credited] link_effects: credit_note_invoice: effect: The new credit note points to the credited invoice cite: { doc: 'billing#credit-notes', quote: Each credit note belongs to exactly one invoice } refund_credit_note: { none: A credit note issued by finance has no refund } invoice_subscription: { none: Crediting keeps the subscription } invoice_customer: { none: Crediting keeps the customer } line_invoice: { none: Crediting does not change lines } attempt_invoice: { none: Crediting does not touch attempts } emits: [CreditNoteIssued] CHARGE_INVOICE: context: Payments doc: 'billing#charge' doc_term: Charge invoice permission: keys: ['payment:charge'] cite: { doc: 'billing#permissions', quote: Charging an invoice needs payment:charge } principals: { kinds: [service, staff], cite: { doc: 'billing#permissions', quote: The scheduler charges invoices; staff can charge one by hand } } idempotencyKey: required: true cite: { doc: 'billing#charge', quote: 'Every charge request carries an idempotency key, so a retried request never charges twice' } parameters: invoice_id: { type: { ref: Invoice }, cite: { doc: 'billing#charge', quote: The charge collects the amount due on invoice_id } } conditions: - id: invoice_open expr: { eq: [{ ref: invoice_id.status }, { lit: OPEN }] } cites: [{ cite: { doc: 'billing#charge', quote: Only an OPEN invoice is charged } }] scenarios: [BIL-04.S1] - id: amount_due_positive unspecified: The amount due is defined only in words, and not for credits larger than the payments reads: [amount_due] cites: [{ cite: { doc: 'billing#charge', quote: Nothing is charged when the amount due is zero } }] - id: payment_method_on_file expr: exists: as: c in: { nav: { from: { ref: invoice_id }, link: invoice_customer, dir: forward } } where: { isNotNull: [{ ref: c.default_payment_method }] } cites: [{ cite: { doc: 'billing#charge', quote: The charge uses the default payment method of the customer } }] decision: hitPolicy: first order: { cite: { doc: 'billing#charge', quote: Charge checks run top to bottom; the first that fails decides } } rows: - when: { fails: invoice_open } result: REJECTED cites: [{ cite: { doc: 'billing#charge', quote: Charging an invoice that is not open is REJECTED } }] - when: { fails: amount_due_positive } result: REJECTED cites: [{ cite: { doc: 'billing#charge', quote: Charging an invoice with nothing due is REJECTED } }] - when: { fails: payment_method_on_file } result: ESCALATED cites: [{ cite: { doc: 'billing#charge', quote: An invoice with no card on file is ESCALATED to collections } }] next: { external: The collections team contacts the customer, cite: { doc: 'billing#dunning', quote: Collections calls customers who cannot be charged automatically } } otherwise: result: CHARGE_SENT cites: [{ cite: { doc: 'billing#charge', quote: Otherwise the attempt is created as PENDING and the result is CHARGE_SENT } }] next: { actions: [RECORD_PAYMENT_RESULT] } creates: [PaymentAttempt] edits: [Invoice.attempt_count] crossContext: via: same_batch cite: { doc: 'billing#charge', quote: The attempt and the new attempt count are written in one batch } link_effects: attempt_invoice: effect: The new attempt points to the charged invoice cite: { doc: 'billing#charge', quote: Each attempt charges one invoice } scenarios: [BIL-04.S1] refund_attempt: { none: A new attempt has no refunds } invoice_subscription: { none: Charging keeps the subscription } invoice_customer: { none: Charging keeps the customer } line_invoice: { none: Charging does not change lines } credit_note_invoice: { none: Charging does not credit } emits: [ChargeRequested] evidence: chargeInvoice RECORD_PAYMENT_RESULT: context: Payments doc: 'billing#processor-results' doc_term: Record payment result permission: none: The processor calls back with a signed message; no user key is involved cite: { doc: 'billing#permissions', quote: Processor callbacks are checked by signature and need no permission key } parameters: attempt_id: { type: { ref: PaymentAttempt }, cite: { doc: 'billing#processor-results', quote: The callback names the attempt_id it reports on } } outcome: { type: enum, cite: { doc: 'billing#processor-results', quote: The outcome is SUCCEEDED or FAILED } } decline_code: { type: enum, optional: true, cite: { doc: 'billing#processor-results', quote: A failed charge comes with a decline_code } } conditions: - id: attempt_pending expr: { eq: [{ ref: attempt_id.status }, { lit: PENDING }] } cites: [{ cite: { doc: 'billing#processor-results', quote: Only a PENDING attempt takes a result } }] - id: invoice_still_open expr: exists: as: i in: { nav: { from: { ref: attempt_id }, link: attempt_invoice, dir: forward } } where: { and: [{ isNotNull: [{ ref: i.status }] }, { eq: [{ ref: i.status }, { lit: OPEN }] }] } cites: [{ cite: { doc: 'billing#processor-results', quote: A result changes the invoice only while the invoice is OPEN } }] decision: hitPolicy: first order: { cite: { doc: 'billing#processor-results', quote: A duplicate callback is checked before a late one } } rows: - when: { fails: attempt_pending } result: REJECTED cites: [{ cite: { doc: 'billing#processor-results', quote: A second result for the same attempt is REJECTED } }] - when: { fails: invoice_still_open } result: LATE_RESULT_KEPT cites: [{ cite: { doc: 'billing#processor-results', quote: A result for a closed invoice is LATE_RESULT_KEPT and changes nothing else } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#processor-results', quote: 'Otherwise the attempt, the invoice and the subscription are updated and the result is APPLIED' } }] } edits: [PaymentAttempt.status, PaymentAttempt.decline_code, Invoice.status, Invoice.amount_paid, Subscription.status] edit_cites: Subscription.status: { cite: { doc: 'billing#processor-results', quote: A failed charge makes an ACTIVE subscription PAST_DUE; a successful one makes a PAST_DUE subscription ACTIVE } } crossContext: via: same_batch cite: { doc: 'billing#processor-results', quote: 'The attempt, its invoice and its subscription change in one batch' } link_effects: attempt_invoice: { none: The result does not move the attempt } refund_attempt: { none: A result does not create refunds } invoice_subscription: { none: The result keeps the subscription } invoice_customer: { none: The result keeps the customer } line_invoice: { none: The result does not change lines } credit_note_invoice: { none: The result does not credit } subscription_customer: { none: The result keeps the customer of the subscription } subscription_price: { none: The result keeps the price } subscription_coupon: { none: The result keeps the coupon } emits: [PaymentSucceeded, PaymentFailed] RETRY_PAYMENT: context: Payments doc: 'billing#dunning' doc_term: Dunning retry permission: keys: ['payment:retry'] cite: { doc: 'billing#permissions', quote: Dunning retries need payment:retry } principals: { kinds: [service], cite: { doc: 'billing#permissions', quote: Only the dunning scheduler runs retries } } parameters: invoice_id: { type: { ref: Invoice }, cite: { doc: 'billing#dunning', quote: Dunning runs once a day for each past due invoice_id } } conditions: - id: invoice_open expr: { eq: [{ ref: invoice_id.status }, { lit: OPEN }] } cites: [{ cite: { doc: 'billing#dunning', quote: Dunning stops when the invoice is no longer OPEN } }] - id: invoice_past_due expr: { derived: { id: invoice_past_due, of: { ref: invoice_id } } } cites: [{ cite: { doc: 'billing#dunning', quote: Dunning starts the day after the due date } }] - id: card_retryable expr: none: as: a in: { nav: { from: { ref: invoice_id }, link: attempt_invoice, dir: reverse } } where: and: - { isNotNull: [{ ref: a.decline_code }] } - { in: [{ ref: a.decline_code }, { lit: [LOST_CARD, STOLEN_CARD] }] } cites: [{ cite: { doc: 'billing#dunning', quote: A card declined as LOST_CARD or STOLEN_CARD is never retried } }] scenarios: [BIL-05.S2] - id: under_retry_limit expr: { lt: [{ ref: invoice_id.attempt_count }, { lit: 4 }] } cites: [{ cite: { doc: 'billing#dunning', quote: An invoice is charged at most 4 times } }] scenarios: [BIL-05.S3] - id: customer_reachable expr: exists: as: c in: { nav: { from: { ref: invoice_id }, link: invoice_customer, dir: forward } } where: and: - { isNotNull: [{ ref: c.email_status }] } - { eq: [{ ref: c.email_status }, { lit: VERIFIED }] } cites: [{ cite: { doc: 'billing#dunning', quote: Retries go ahead only when the customer has a VERIFIED billing email to warn } }] decision: hitPolicy: first order: { cite: { doc: 'billing#dunning', quote: The dunning checks run in the order of this section; the first that fails decides } } rows: - when: { fails: invoice_open } result: REJECTED cites: [{ cite: { doc: 'billing#dunning', quote: A retry of a closed invoice is REJECTED } }] - when: { fails: invoice_past_due } result: REJECTED cites: [{ cite: { doc: 'billing#dunning', quote: A retry before the due date has passed is REJECTED } }] - when: { fails: card_retryable } result: MARKED_UNCOLLECTIBLE cites: [{ cite: { doc: 'billing#dunning', quote: After a lost or stolen card the invoice is MARKED_UNCOLLECTIBLE at once } }] - when: { fails: under_retry_limit } result: MARKED_UNCOLLECTIBLE cites: [{ cite: { doc: 'billing#dunning', quote: After the last retry the invoice is MARKED_UNCOLLECTIBLE and the subscription is canceled } }] - when: { fails: customer_reachable } result: ESCALATED cites: [{ cite: { doc: 'billing#dunning', quote: A customer we cannot email is ESCALATED to collections before any retry } }] next: { external: The collections team calls the customer, cite: { doc: 'billing#dunning', quote: Collections phones the customer within two working days } } otherwise: result: RETRY_SCHEDULED cites: [{ cite: { doc: 'billing#dunning', quote: 'Otherwise the next charge is RETRY_SCHEDULED for 3, 5 or 7 days later' } }] next: { actions: [CHARGE_INVOICE] } edits: [Invoice.status, Subscription.status] edit_cites: Invoice.status: { cite: { doc: 'billing#dunning', quote: An uncollectible invoice moves from OPEN to UNCOLLECTIBLE } } Subscription.status: { cite: { doc: 'billing#dunning', quote: Writing off the invoice cancels a PAST_DUE subscription } } crossContext: via: same_batch cite: { doc: 'billing#dunning', quote: The write-off and the cancellation are one batch } link_effects: invoice_subscription: { none: Dunning keeps the subscription } invoice_customer: { none: Dunning keeps the customer } line_invoice: { none: Dunning does not change lines } attempt_invoice: { none: The retry itself is a later CHARGE_INVOICE } credit_note_invoice: { none: Dunning does not credit } subscription_customer: { none: Dunning keeps the customer of the subscription } subscription_price: { none: Dunning keeps the price } subscription_coupon: { none: Dunning keeps the coupon } emits: [DunningStep] ISSUE_REFUND: context: Payments doc: 'billing#refunds' doc_term: Issue refund permission: keys: ['payment:refund'] cite: { doc: 'billing#permissions', quote: Refunds need payment:refund } idempotencyKey: required: true cite: { doc: 'billing#refunds', quote: A refund request must carry an idempotency key; the processor rejects a refund without one } parameters: attempt_id: { type: { ref: PaymentAttempt }, cite: { doc: 'billing#refunds', quote: Support refunds the attempt_id the customer paid with } } amount: { type: integer, cite: { doc: 'billing#refunds', quote: Support enters the amount to return } } conditions: - id: attempt_succeeded expr: { eq: [{ ref: attempt_id.status }, { lit: SUCCEEDED }] } cites: [{ cite: { doc: 'billing#refunds', quote: Only a SUCCEEDED attempt can be refunded } }] - id: amount_positive expr: { gt: [{ ref: amount }, { lit: 0 }] } cites: [{ cite: { doc: 'billing#refunds', quote: A refund is for more than 0 } }] - id: amount_within_attempt expr: { lte: [{ plus: [{ ref: amount }, { ref: attempt_id.amount_refunded }] }, { ref: attempt_id.amount }] } cites: [{ cite: { doc: 'billing#refunds', quote: All refunds of an attempt together may not exceed the amount of the attempt } }] decision: hitPolicy: first rows: - when: { fails: attempt_succeeded } result: REJECTED cites: [{ cite: { doc: 'billing#refunds', quote: Refunding a failed or pending attempt is REJECTED } }] - when: { fails: amount_positive } result: REJECTED cites: [{ cite: { doc: 'billing#refunds', quote: A refund of zero is REJECTED } }] - when: { fails: amount_within_attempt } result: REJECTED cites: [{ cite: { doc: 'billing#refunds', quote: A refund above what is left of the attempt amount is REJECTED } }] otherwise: { result: APPLIED, cites: [{ cite: { doc: 'billing#refunds', quote: Otherwise the refund and its credit note are created and the refund is APPLIED } }] } creates: [Refund, CreditNote] edits: [PaymentAttempt.amount_refunded, Invoice.amount_credited] edit_cites: PaymentAttempt.amount_refunded: { cite: { doc: 'billing#refunds', quote: Refunds add to amount_refunded on the attempt they return } } Invoice.amount_credited: { cite: { doc: 'billing#credit-notes', quote: Each credit note adds to amount_credited on its invoice } } crossContext: unspecified: The refund section does not say how Payments writes the credit note in Invoicing cite: { doc: 'billing#refunds', quote: Every refund gets a matching credit note on the invoice } link_effects: refund_attempt: effect: The new refund points to the refunded attempt cite: { doc: 'billing#refunds', quote: The refund records which attempt it returns } refund_credit_note: effect: The new refund points to its credit note cite: { doc: 'billing#refunds', quote: The refund keeps a reference to its credit note } credit_note_invoice: effect: The new credit note points to the invoice of the attempt cite: { doc: 'billing#refunds', quote: The credit note goes on the invoice the attempt paid } attempt_invoice: { none: A refund does not move the attempt } invoice_customer: { none: A refund keeps the customer of the invoice } invoice_subscription: { none: A refund keeps the subscription of the invoice } line_invoice: { none: A refund does not change invoice lines } emits: [RefundIssued] evidence: issueRefund